diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml
index d214037c95..adca5e3167 100644
--- a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml
+++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml
@@ -1,87 +1,89 @@
-name: "đ Bug Report"
+name: 'đ Bug Report'
description: "Tell us about something that's not working the way we (probably) intend."
-labels: ["Platform: React-Native", "Type: đǞ Bug"]
+labels: ['Platform: React-Native', 'Type: đǞ Bug']
+type: Bug
body:
- type: dropdown
id: environment
validations:
required: true
attributes:
- label: "What React Native libraries do you use?"
- description: "Select all options that describe your application."
+ label: 'What React Native libraries do you use?'
+ description: 'Select all options that describe your application.'
multiple: true
options:
- - "React Native without Frameworks"
- - "React Navigation"
- - "Hermes"
- - "RN New Architecture"
- - "Expo Application Services (EAS)"
- - "Expo (mobile only)"
- - "Expo Web"
- - "Expo Router"
- - "React Native Web"
- - "React Native Navigation by Wix"
+ - 'React Native without Frameworks'
+ - 'React Navigation'
+ - 'Hermes'
+ - 'RN New Architecture'
+ - 'Expo Application Services (EAS)'
+ - 'Expo (mobile only)'
+ - 'Expo Web'
+ - 'Expo Router'
+ - 'React Native Web'
+ - 'React Native Navigation by Wix'
- type: dropdown
id: sentry
validations:
required: true
attributes:
- label: "Are you using sentry.io or on-premise?"
- description: "Select exactly one option."
+ label: 'Are you using sentry.io or on-premise?'
+ description: 'Select exactly one option.'
options:
- - "sentry.io (SaS)"
- - "on-premise (Self-Hosted)"
+ - 'sentry.io (SaS)'
+ - 'on-premise (Self-Hosted)'
- type: input
id: version
validations:
required: true
attributes:
- label: "@sentry/react-native SDK Version"
- description: "If the issue started after the SDK upgrade, please input both old and new versions."
- placeholder: "5.33.1 â should look like this"
+ label: '@sentry/react-native SDK Version'
+ description: 'If the issue started after the SDK upgrade, please input both old and new versions.'
+ placeholder: '5.33.1 â should look like this'
- type: textarea
id: doctor
validations:
required: true
attributes:
- label: "How does your development environment look like?"
- description: "Output of the command `npx react-native@latest info` or manully describe your development environment?"
- placeholder: |-
- info Fetching system and libraries information...
- OS: OS version
- Node: Your version
- Yarn: Yarn version
- Expo SDK: Expo SDK version
- react: React version
- react-native: React Native version
- hermesEnabled: bool
- newArchEnabled: bool
+ label: 'How does your development environment look like?'
+ description:
+ 'Output of the command `npx react-native@latest info` or manully describe your development environment?'
+ value: |-
+ ````
+ âŦ Place the `npx react-native@latest info` output here. âŦ
+
+
+
+
+ ````
- type: textarea
id: init
validations:
required: true
attributes:
- label: "Sentry.init()"
- description: "Code snipped of Sentry initialization from your application."
- placeholder: |-
+ label: 'Sentry.init()'
+ description: 'Code snipped of Sentry initialization from your application.'
+ value: |-
+ ````js
Sentry.init({
dsn: 'https://...@sentry.io/...'
// other options
});
+ ````
- type: textarea
id: repro
validations:
required: true
attributes:
- label: "Steps to Reproduce"
+ label: 'Steps to Reproduce'
description: "How can we see what you're seeing? Specific is terrific."
placeholder: |-
- 1. Build Android using `npx react-native run-android --mode Debug`
+ 1. Build Android using `npx react-native run-android --mode Debug`
2. Start Metro Dev server using `npx react-native start`
3. Click on button executing `Sentry.capture(new Error("This is not captured :("))`
@@ -90,15 +92,15 @@ body:
validations:
required: true
attributes:
- label: "Expected Result"
+ label: 'Expected Result'
- type: textarea
id: actual
validations:
required: true
attributes:
- label: "Actual Result"
- description: "JS Console? iOS Console? Logcat? Screenshots? Yes, please."
+ label: 'Actual Result'
+ description: 'JS Console? iOS Console? Logcat? Screenshots? Yes, please.'
- type: markdown
attributes:
diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md
deleted file mode 100644
index 21a6560ed5..0000000000
--- a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: Request a new feature
-about: New feature for the @sentry/react-native package.
-labels: ["Platform: React-Native", "enhancement"]
----
-
-
diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml
new file mode 100644
index 0000000000..84d067a56c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml
@@ -0,0 +1,37 @@
+name: đĄ Feature Request
+description: Tell us about a problem our SDK could solve but doesn't.
+labels: ['Platform: React-Native', 'enhancement']
+type: Feature
+body:
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem Statement
+ description: What problem could Sentry solve that it doesn't?
+ placeholder: |-
+ I want to make whirled peas, but Sentry doesn't blend.
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected
+ attributes:
+ label: Solution Brainstorm
+ description: We know you have bright ideas to share ... share away, friend.
+ placeholder: |-
+ Add a blender to Sentry.
+
+ - type: dropdown
+ id: submit-a-pr
+ attributes:
+ label: Are you willing to submit a PR?
+ description: We accept contributions!
+ options:
+ - 'Yes'
+ - 'No'
+
+ - type: markdown
+ attributes:
+ value: |-
+ ## Thanks đ
+ Check our [triage docs](https://open.sentry.io/triage/) for what to expect next.
diff --git a/.github/workflows/buildandtest.yml b/.github/workflows/buildandtest.yml
index 182341bc60..7da3d56afc 100644
--- a/.github/workflows/buildandtest.yml
+++ b/.github/workflows/buildandtest.yml
@@ -23,7 +23,7 @@ jobs:
if: ${{ needs.diff_check.outputs.skip_ci != 'true' }}
steps:
- uses: actions/checkout@v4
- - run: corepack enable
+ - run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
@@ -41,7 +41,7 @@ jobs:
if: ${{ needs.diff_check.outputs.skip_ci != 'true' }}
steps:
- uses: actions/checkout@v4
- - run: corepack enable
+ - run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
@@ -59,7 +59,7 @@ jobs:
if: ${{ needs.diff_check.outputs.skip_ci != 'true' }}
steps:
- uses: actions/checkout@v4
- - run: corepack enable
+ - run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
@@ -82,7 +82,7 @@ jobs:
if: ${{ needs.diff_check.outputs.skip_ci != 'true' }}
steps:
- uses: actions/checkout@v4
- - run: corepack enable
+ - run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
@@ -125,7 +125,7 @@ jobs:
YARN_ENABLE_IMMUTABLE_INSTALLS: false
steps:
- uses: actions/checkout@v4
- - run: corepack enable
+ - run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
@@ -155,7 +155,7 @@ jobs:
if: ${{ needs.diff_check.outputs.skip_ci != 'true' }}
steps:
- uses: actions/checkout@v4
- - run: corepack enable
+ - run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
@@ -189,7 +189,7 @@ jobs:
dev: [true, false]
steps:
- uses: actions/checkout@v4
- - run: corepack enable
+ - run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
diff --git a/.github/workflows/codegen.yml b/.github/workflows/codegen.yml
index f642eb97ba..9520480de2 100644
--- a/.github/workflows/codegen.yml
+++ b/.github/workflows/codegen.yml
@@ -37,7 +37,7 @@ jobs:
--targetPlatform ios
steps:
- uses: actions/checkout@v4
- - run: corepack enable
+ - run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 2158bf0e6e..0f0faeaf15 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -44,7 +44,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # pin@v3.28.9
+ uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # pin@v3.28.19
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -55,7 +55,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # pin@v3.28.9
+ uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # pin@v3.28.19
# âšī¸ Command-line programs to run using the OS shell.
# đ https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
@@ -66,4 +66,4 @@ jobs:
# make bootstrap
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # pin@v3.28.9
+ uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # pin@v3.28.19
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e-v2.yml
similarity index 94%
rename from .github/workflows/e2e.yml
rename to .github/workflows/e2e-v2.yml
index 4fe74bd43d..e7a4738921 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e-v2.yml
@@ -1,4 +1,4 @@
-name: End-to-End Tests
+name: End-to-End Tests V2
on:
push:
@@ -14,7 +14,7 @@ concurrency:
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- MAESTRO_VERSION: '1.39.0'
+ MAESTRO_VERSION: '1.40.3'
IOS_DEVICE: 'iPhone 16'
IOS_VERSION: '18.1'
@@ -56,7 +56,7 @@ jobs:
- run: sudo xcode-select -s /Applications/Xcode_14.2.app/Contents/Developer
if: ${{ matrix.platform == 'ios' }}
- - run: corepack enable
+ - run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
@@ -151,18 +151,19 @@ jobs:
react-native-build:
name: Build RN ${{ matrix.rn-version }} ${{ matrix.rn-architecture }} ${{ matrix.engine }} ${{ matrix.platform }} ${{ matrix.build-type }} ${{ matrix.ios-use-frameworks }}
runs-on: ${{ matrix.runs-on }}
- needs: [diff_check]
- if: ${{ needs.diff_check.outputs.skip_ci != 'true' }}
+ needs: [diff_check, auth_token_check]
+ if: ${{ needs.diff_check.outputs.skip_ci != 'true' && needs.auth_token_check.outputs.skip_ci != 'true' && !startsWith(github.ref, 'refs/heads/release/') }}
env:
RN_VERSION: ${{ matrix.rn-version }}
RN_ENGINE: ${{ matrix.engine }}
USE_FRAMEWORKS: ${{ matrix.ios-use-frameworks }}
PRODUCTION: ${{ matrix.build-type == 'production' && '1' || '0' }}
RCT_NEW_ARCH_ENABLED: ${{ matrix.rn-architecture == 'new' && '1' || '0' }}
+ SENTRY_DISABLE_AUTO_UPLOAD: 'false'
strategy:
fail-fast: false # keeps matrix running if one fails
matrix:
- rn-version: ['0.65.3', '0.77.1']
+ rn-version: ['0.65.3', '0.79.1']
rn-architecture: ['legacy', 'new']
platform: ['android', 'ios']
build-type: ['production']
@@ -170,16 +171,18 @@ jobs:
engine: ['hermes', 'jsc']
include:
- platform: ios
- rn-version: '0.77.1'
- runs-on: macos-14
+ rn-version: '0.79.1'
+ xcode-version: '16.2'
+ runs-on: macos-15
- platform: ios
rn-version: '0.65.3'
+ xcode-version: '14.2'
runs-on: macos-13
- platform: android
runs-on: ubuntu-latest
exclude:
# exclude JSC for new RN versions (keeping the matrix manageable)
- - rn-version: '0.77.1'
+ - rn-version: '0.79.1'
engine: 'jsc'
# exclude all rn versions lower than 0.70.0 for new architecture
- rn-version: '0.65.3'
@@ -221,10 +224,10 @@ jobs:
echo "SENTRY_RELEASE=$SENTRY_RELEASE"
echo "SENTRY_DIST=$SENTRY_DIST"
- - run: sudo xcode-select -s /Applications/Xcode_14.2.app/Contents/Developer
- if: ${{ matrix.platform == 'ios' && matrix.rn-version == '0.65.3' }}
+ - run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode-version }}.app/Contents/Developer
+ if: ${{ matrix.platform == 'ios' }}
- - run: corepack enable
+ - run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
@@ -298,7 +301,7 @@ jobs:
strategy:
fail-fast: false # keeps matrix running if one fails
matrix:
- rn-version: ['0.65.3', '0.77.1']
+ rn-version: ['0.65.3', '0.79.1']
rn-architecture: ['legacy', 'new']
platform: ['android', 'ios']
build-type: ['production']
@@ -306,7 +309,7 @@ jobs:
engine: ['hermes', 'jsc']
include:
- platform: ios
- rn-version: '0.77.1'
+ rn-version: '0.79.1'
runs-on: macos-15
- platform: ios
rn-version: '0.65.3'
@@ -320,7 +323,7 @@ jobs:
# e2e test only the default combinations
- rn-version: '0.65.3'
engine: 'hermes'
- - rn-version: '0.77.1'
+ - rn-version: '0.79.1'
engine: 'jsc'
steps:
@@ -350,9 +353,7 @@ jobs:
path: dev-packages/e2e-tests
- name: Enable Corepack
- run: |
- npm install -g corepack@0.29.4
- corepack enable
+ run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 20
@@ -384,7 +385,7 @@ jobs:
- name: Run tests on Android
if: ${{ matrix.platform == 'android' }}
- uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # pin@v2.33.0
+ uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # pin@v2.34.0
with:
api-level: 30
force-avd-creation: false
diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml
index 8382f3d3c1..afaa4909ff 100644
--- a/.github/workflows/native-tests.yml
+++ b/.github/workflows/native-tests.yml
@@ -25,9 +25,7 @@ jobs:
- uses: actions/checkout@v4
- name: Enable Corepack
- run: |
- npm install -g corepack@0.29.4
- corepack enable
+ run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
@@ -88,7 +86,7 @@ jobs:
sudo udevadm trigger --name-match=kvm
- name: Run connected tests
- uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d #pin@v2.33.0
+ uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed #pin@v2.34.0
with:
working-directory: packages/core/RNSentryAndroidTester
api-level: 30
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 525b4afe0c..9f785ded04 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Get auth token
id: token
- uses: actions/create-github-app-token@0d564482f06ca65fa9e77e2510873638c82206f2 # v1.11.5
+ uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }}
private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }}
@@ -28,7 +28,7 @@ jobs:
with:
token: ${{ steps.token.outputs.token }}
fetch-depth: 0
- - run: corepack enable
+ - run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
diff --git a/.github/workflows/sample-application-expo.yml b/.github/workflows/sample-application-expo.yml
index 024d54d389..5830f3e5fd 100644
--- a/.github/workflows/sample-application-expo.yml
+++ b/.github/workflows/sample-application-expo.yml
@@ -47,9 +47,7 @@ jobs:
- uses: actions/checkout@v4
- name: Enable Corepack
- run: |
- npm install -g corepack@0.29.4
- corepack enable
+ run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
diff --git a/.github/workflows/sample-application.yml b/.github/workflows/sample-application.yml
index 899c341dbf..3520e11e92 100644
--- a/.github/workflows/sample-application.yml
+++ b/.github/workflows/sample-application.yml
@@ -13,6 +13,7 @@ concurrency:
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+ MAESTRO_VERSION: '1.40.3'
RN_SENTRY_POD_NAME: RNSentry
IOS_APP_ARCHIVE_PATH: sentry-react-native-sample.app.zip
ANDROID_APP_ARCHIVE_PATH: sentry-react-native-sample.apk.zip
@@ -60,9 +61,7 @@ jobs:
- uses: actions/checkout@v4
- name: Enable Corepack
- run: |
- npm install -g corepack@0.29.4
- corepack enable
+ run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
@@ -185,8 +184,8 @@ jobs:
name: build-sample-${{ matrix.rn-architecture }}-${{ matrix.platform }}-${{ matrix.build-type }}-${{ matrix.ios-use-frameworks}}-logs
path: ${{ env.REACT_NATIVE_SAMPLE_PATH }}/${{ matrix.platform }}/*.log
- test-detox:
- name: ${{ matrix.job-name }}
+ test:
+ name: Test ${{ matrix.platform }} ${{ matrix.build-type }} ${{ matrix.init-type }} REV2
runs-on: ${{ matrix.runs-on }}
needs: [diff_check, build]
if: ${{ needs.diff_check.outputs.skip_ci != 'true' }}
@@ -195,32 +194,34 @@ jobs:
fail-fast: false
matrix:
include:
- - job-name: 'Test iOS Release Auto Init'
- platform: ios
+ - platform: ios
runs-on: macos-15
rn-architecture: 'new'
ios-use-frameworks: 'no-frameworks'
build-type: 'production'
- test-command: 'yarn test-ios-auto' # tests native auto init from JS
+ init-type: 'auto'
- - job-name: 'Test iOS Release Manual Init'
- platform: ios
+ - platform: ios
runs-on: macos-15
rn-architecture: 'new'
ios-use-frameworks: 'no-frameworks'
build-type: 'production'
- test-command: 'yarn test-ios-manual'
+ init-type: 'manual'
- - job-name: 'Test Android Release Manual Init'
- platform: android
+ - platform: android
runs-on: ubuntu-latest
rn-architecture: 'new'
build-type: 'production'
- test-command: 'yarn test-android'
+ init-type: 'manual'
steps:
- uses: actions/checkout@v4
+ - name: Install Maestro
+ uses: dniHze/maestro-test-action@bda8a93211c86d0a05b7a4597c5ad134566fbde4 # pin@v1.0.0
+ with:
+ version: ${{env.MAESTRO_VERSION}}
+
- name: Download iOS App Archive
if: ${{ matrix.platform == 'ios' }}
uses: actions/download-artifact@v4
@@ -243,12 +244,12 @@ jobs:
- name: Unzip Android APK
if: ${{ matrix.platform == 'android' }}
working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }}
- run: unzip ${{ env.ANDROID_APP_ARCHIVE_PATH }}
+ run: |
+ unzip ${{ env.ANDROID_APP_ARCHIVE_PATH }}
+ rm app-androidTest.apk
- name: Enable Corepack
- run: |
- npm install -g corepack@0.29.4
- corepack enable
+ run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
@@ -258,15 +259,6 @@ jobs:
- name: Install JS Dependencies
run: yarn install
- - name: Install Detox
- run: npm install -g detox-cli@20.0.0
-
- - name: Install Apple Simulator Utilities
- if: ${{ matrix.platform == 'ios' }}
- run: |
- brew tap wix/brew
- brew install applesimutils
-
- name: Setup KVM
if: ${{ matrix.platform == 'android' }}
shell: bash
@@ -279,25 +271,21 @@ jobs:
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- - uses: futureware-tech/simulator-action@dab10d813144ef59b48d401cd95da151222ef8cd # pin@v4
+ - name: Boot ${{ env.IOS_DEVICE }} with iOS ${{ env.IOS_VERSION }}
+ uses: futureware-tech/simulator-action@dab10d813144ef59b48d401cd95da151222ef8cd # pin@v4
if: ${{ matrix.platform == 'ios' }}
with:
- # the same envs are used by Detox ci.sim configuration
model: ${{ env.IOS_DEVICE }}
os_version: ${{ env.IOS_VERSION }}
- - name: Run Detox iOS Tests
+ - name: Run iOS Tests
if: ${{ matrix.platform == 'ios' }}
working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }}
- run: ${{ matrix.test-command }}
+ run: yarn test-ios-${{ matrix.init-type }}
- - name: Run tests on Android
+ - name: Run Android Tests on API ${{ env.ANDROID_API_LEVEL }}
if: ${{ matrix.platform == 'android' }}
- env:
- # used by Detox ci.android configuration
- ANDROID_AVD_NAME: 'test' # test is default reactivecircus/android-emulator-runner name
- ANDROID_TYPE: 'android.emulator'
- uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # pin@v2.33.0
+ uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # pin@v2.34.0
with:
api-level: ${{ env.ANDROID_API_LEVEL }}
force-avd-creation: false
@@ -315,4 +303,4 @@ jobs:
-camera-front none
-timezone US/Pacific
working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }}
- script: ${{ matrix.test-command }}
+ script: yarn test-android-${{ matrix.init-type }}
diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml
index 3044c95448..06bba8da11 100644
--- a/.github/workflows/testflight.yml
+++ b/.github/workflows/testflight.yml
@@ -14,19 +14,19 @@ jobs:
upload_to_testflight:
name: Build and Upload React Native Sample to Testflight
- runs-on: macos-14
+ runs-on: macos-15
needs: [diff_check]
if: ${{ needs.diff_check.outputs.skip_ci != 'true' }}
steps:
- uses: actions/checkout@v4
- - run: sudo xcode-select -s /Applications/Xcode_15.3.app/Contents/Developer
+ - run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer
- uses: ruby/setup-ruby@v1
with:
working-directory: samples/react-native
ruby-version: '3.3.0' # based on what is used in the sample
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
cache-version: 1 # cache the installed gems
- - run: corepack enable
+ - run: npm i -g corepack
- uses: actions/setup-node@v4
with:
node-version: 18
diff --git a/.github/workflows/update-deps.yml b/.github/workflows/update-deps.yml
index c34d114f7f..8bdad428d5 100644
--- a/.github/workflows/update-deps.yml
+++ b/.github/workflows/update-deps.yml
@@ -75,3 +75,14 @@ jobs:
changelog-entry: false
secrets:
api-token: ${{ secrets.CI_DEPLOY_KEY }}
+
+ maestro:
+ uses: getsentry/github-workflows/.github/workflows/updater.yml@v2
+ with:
+ path: scripts/update-maestro.sh
+ name: Maestro
+ pattern: '^v[0-9.]+$' # only match non-preview versions
+ pr-strategy: update
+ changelog-entry: false
+ secrets:
+ api-token: ${{ secrets.CI_DEPLOY_KEY }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef04ec37c7..5e78227557 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,281 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
-## Unreleased
+## 6.15.1
+
+### Dependencies
+
+- Bump Cocoa SDK from v8.52.0 to v8.52.1 ([#4899](https://github.com/getsentry/sentry-react-native/pull/4899))
+ - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8521)
+ - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.52.0...8.52.1)
+
+## 6.15.0
+
+### Features
+
+- User Feedback Widget Updates
+ - `FeedbackButton` for easy access to the widget ([#4378](https://github.com/getsentry/sentry-react-native/pull/4378))
+ - `ScreenshotButton` for capturing the application visuals ([#4714](https://github.com/getsentry/sentry-react-native/issues/4714))
+ - Theming support to better align with the application styles ([#4677](https://github.com/getsentry/sentry-react-native/pull/4677))
+
+ ```js
+ Sentry.init({
+ integrations: [
+ Sentry.feedbackIntegration({
+ enableTakeScreenshot: true, // Enables `ScreenshotButton`
+ themeDark: {
+ // Add dark theme styles here
+ },
+ themeLight: {
+ // Add light theme styles here
+ },
+ }),
+ ],
+ });
+
+ Sentry.showFeedbackButton();
+ Sentry.hideFeedbackButton();
+ ```
+
+ To learn more visit [the documentation](https://docs.sentry.io/platforms/react-native/user-feedback).
+
+- Re-export `ErrorEvent` and `TransactionEvent` types ([#4859](https://github.com/getsentry/sentry-react-native/pull/4859))
+
+### Fixes
+
+- crashedLastRun now returns the correct value ([#4829](https://github.com/getsentry/sentry-react-native/pull/4829))
+- Use engine-specific promise rejection tracking ([#4826](https://github.com/getsentry/sentry-react-native/pull/4826))
+- Fixes Feedback Widget accessibility issue on iOS ([#4739](https://github.com/getsentry/sentry-react-native/pull/4739))
+- Measuring TTID or TTFD could cause a crash when `parentSpanId` was removed ([#4881](https://github.com/getsentry/sentry-react-native/pull/4881))
+
+### Dependencies
+
+- Bump Bundler Plugins from v3.4.0 to v3.5.0 ([#4850](https://github.com/getsentry/sentry-react-native/pull/4850))
+ - [changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md#350)
+ - [diff](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/3.4.0...3.5.0)
+- Bump Cocoa SDK from v8.50.2 to v8.52.0 ([#4839](https://github.com/getsentry/sentry-react-native/pull/4839), [#4887](https://github.com/getsentry/sentry-react-native/pull/4887))
+ - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8520)
+ - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.50.2...8.52.0)
+- Bump CLI from v2.45.0 to v2.46.0 ([#4866](https://github.com/getsentry/sentry-react-native/pull/4866))
+ - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2460)
+ - [diff](https://github.com/getsentry/sentry-cli/compare/2.45.0...2.46.0)
+
+## 6.14.0
+
+### Fixes
+
+- Expo Updates Context is passed to native after native init to be available for crashes ([#4808](https://github.com/getsentry/sentry-react-native/pull/4808))
+- Expo Updates Context values should all be lowercase ([#4809](https://github.com/getsentry/sentry-react-native/pull/4809))
+- Avoid duplicate network requests (fetch, xhr) by default ([#4816](https://github.com/getsentry/sentry-react-native/pull/4816))
+ - `traceFetch` is disabled by default on mobile as RN uses a polyfill which will be traced by `traceXHR`
+
+### Changes
+
+- Renames `enableExperimentalViewRenderer` to `enableViewRendererV2` which is enabled by default for up to 5x times more performance in Session Replay on iOS ([#4815](https://github.com/getsentry/sentry-react-native/pull/4815))
+
+### Dependencies
+
+- Bump CLI from v2.43.1 to v2.45.0 ([#4804](https://github.com/getsentry/sentry-react-native/pull/4804), [#4818](https://github.com/getsentry/sentry-react-native/pull/4818))
+ - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2450)
+ - [diff](https://github.com/getsentry/sentry-cli/compare/2.43.1...2.45.0)
+- Bump Bundler Plugins from v3.3.1 to v3.4.0 ([#4805](https://github.com/getsentry/sentry-react-native/pull/4805))
+ - [changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md#340)
+ - [diff](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/3.3.1...3.4.0)
+- Bump Cocoa SDK from v8.49.2 to v8.50.2 ([#4807](https://github.com/getsentry/sentry-react-native/pull/4807), [#4821](https://github.com/getsentry/sentry-react-native/pull/4821), [#4830](https://github.com/getsentry/sentry-react-native/pull/4830))
+ - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8502)
+ - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.49.2...8.50.2)
+
+## 6.13.1
+
+### Fixes
+
+- Disable native driver for Feedback Widget `backgroundColor` animation in unsupported React Native versions ([#4794](https://github.com/getsentry/sentry-react-native/pull/4794))
+- Fix Debug Symbolicator for local development builds (use RN 0.79 default exports) ([#4801](https://github.com/getsentry/sentry-react-native/pull/4801))
+
+## 6.13.0
+
+### Changes
+
+- Fallback to Current Activity Holder when React Context Activity is not present ([#4779](https://github.com/getsentry/sentry-react-native/pull/4779))
+- Support `REACT_NATIVE_PATH` env in Xcode Debug Files upload scripts ([#4789](https://github.com/getsentry/sentry-react-native/pull/4789))
+
+### Fixes
+
+- Initialize Sentry Android with ApplicationContext if available ([#4780](https://github.com/getsentry/sentry-react-native/pull/4780))
+
+### Dependencies
+
+- Bump Cocoa SDK from v8.49.1 to v8.49.2 ([#4792](https://github.com/getsentry/sentry-react-native/pull/4792))
+ - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8492)
+ - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.49.1...8.49.2)
+
+## 6.12.0
+
+### Features
+
+- Add Expo Updates Event Context ([#4767](https://github.com/getsentry/sentry-react-native/pull/4767), [#4786](https://github.com/getsentry/sentry-react-native/pull/4786))
+ - Automatically collects `updateId`, `channel`, Emergency Launch Reason and other Expo Updates constants
+
+### Fixes
+
+- Export `extraErrorDataIntegration` from `@sentry/core` ([#4762](https://github.com/getsentry/sentry-react-native/pull/4762))
+- Remove `@sentry-internal/replay` when `includeWebReplay: false` ([#4774](https://github.com/getsentry/sentry-react-native/pull/4774))
+
+### Dependencies
+
+- Bump Cocoa SDK from v8.49.0 to v8.49.1 ([#4771](https://github.com/getsentry/sentry-react-native/pull/4771))
+ - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8491)
+ - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.49.0...8.49.1)
+- Bump CLI from v2.43.0 to v2.43.1 ([#4787](https://github.com/getsentry/sentry-react-native/pull/4787))
+ - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2431)
+ - [diff](https://github.com/getsentry/sentry-cli/compare/2.43.0...2.43.1)
+
+## 6.11.0
+
+### Features
+
+- Improve Warm App Start reporting on Android ([#4641](https://github.com/getsentry/sentry-react-native/pull/4641), [#4695](https://github.com/getsentry/sentry-react-native/pull/4695))
+- Add `createTimeToInitialDisplay({useFocusEffect})` and `createTimeToFullDisplay({useFocusEffect})` to allow record full display on screen focus ([#4665](https://github.com/getsentry/sentry-react-native/pull/4665))
+- Add support for measuring Time to Initial Display for already seen routes ([#4661](https://github.com/getsentry/sentry-react-native/pull/4661))
+ - Introduce `enableTimeToInitialDisplayForPreloadedRoutes` option to the React Navigation integration.
+
+ ```js
+ Sentry.reactNavigationIntegration({
+ enableTimeToInitialDisplayForPreloadedRoutes: true,
+ });
+ ```
+
+- Add `useDispatchedActionData` option to the React Navigation integration to filter out navigation actions that should not create spans ([#4684](https://github.com/getsentry/sentry-react-native/pull/4684))
+ - For example `PRELOAD`, `SET_PARAMS`, `TOGGLE_DRAWER` and others.
+
+ ```js
+ Sentry.reactNavigationIntegration({
+ useDispatchedActionData: true,
+ });
+ ```
+
+### Fixes
+
+- Equalize TTID and TTFD duration when TTFD manual API is called and resolved before auto TTID ([#4680](https://github.com/getsentry/sentry-react-native/pull/4680))
+- Avoid loading Sentry native components in Expo Go ([#4696](https://github.com/getsentry/sentry-react-native/pull/4696))
+- Avoid silent failure when JS bundle was not created due to Sentry Xcode scripts failure ([#4690](https://github.com/getsentry/sentry-react-native/pull/4690))
+- Prevent crash on iOS during profiling stop when debug images are missing ([#4738](https://github.com/getsentry/sentry-react-native/pull/4738))
+- Attach only App Starts within the 60s threshold (fixed comparison units, use ms) ([#4746](https://github.com/getsentry/sentry-react-native/pull/4746))
+- Add missing `popTimeToDisplayFor` in to the Android Old Arch Native interface([#4751](https://github.com/getsentry/sentry-react-native/pull/4751))
+
+### Changes
+
+- Change `gradle.projectsEvaluated` to `project.afterEvaluate` in the Sentry Gradle Plugin to fix tasks not being created when using `--configure-on-demand` ([#4687](https://github.com/getsentry/sentry-react-native/pull/4687))
+- Remove `SENTRY_FORCE_FOREGROUND` from Xcode Scripts as the underlying `--force-foreground` Sentry CLI is no-op since v2.37.0 ([#4689](https://github.com/getsentry/sentry-react-native/pull/4689))
+- TTID and TTFD use native getters instead od events to pass timestamps to the JS layer ([#4669](https://github.com/getsentry/sentry-react-native/pull/4669), [#4681](https://github.com/getsentry/sentry-react-native/pull/4681))
+
+### Dependencies
+
+- Bump Bundler Plugins from v3.2.2 to v3.3.1 ([#4693](https://github.com/getsentry/sentry-react-native/pull/4693), [#4707](https://github.com/getsentry/sentry-react-native/pull/4707), [#4720](https://github.com/getsentry/sentry-react-native/pull/4720), [#4721](https://github.com/getsentry/sentry-react-native/pull/4721))
+ - [changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md#331)
+ - [diff](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/3.2.2...3.3.1)
+- Bump CLI from v2.42.4 to v2.43.0 ([#4692](https://github.com/getsentry/sentry-react-native/pull/4692))
+ - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2430)
+ - [diff](https://github.com/getsentry/sentry-cli/compare/2.42.4...2.43.0)
+- Bump Cocoa SDK from v8.48.0 to v8.49.0 ([#4742](https://github.com/getsentry/sentry-react-native/pull/4742))
+ - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8490)
+ - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.48.0...8.49.0)
+
+## 6.11.0-beta.0
+
+### Features
+
+- Improve Warm App Start reporting on Android ([#4641](https://github.com/getsentry/sentry-react-native/pull/4641), [#4695](https://github.com/getsentry/sentry-react-native/pull/4695))
+- Add `createTimeToInitialDisplay({useFocusEffect})` and `createTimeToFullDisplay({useFocusEffect})` to allow record full display on screen focus ([#4665](https://github.com/getsentry/sentry-react-native/pull/4665))
+- Add support for measuring Time to Initial Display for already seen routes ([#4661](https://github.com/getsentry/sentry-react-native/pull/4661))
+ - Introduce `enableTimeToInitialDisplayForPreloadedRoutes` option to the React Navigation integration.
+
+ ```js
+ Sentry.reactNavigationIntegration({
+ enableTimeToInitialDisplayForPreloadedRoutes: true,
+ });
+ ```
+
+- Add `useDispatchedActionData` option to the React Navigation integration to filter out navigation actions that should not create spans ([#4684](https://github.com/getsentry/sentry-react-native/pull/4684))
+ - For example `PRELOAD`, `SET_PARAMS`, `TOGGLE_DRAWER` and others.
+
+ ```js
+ Sentry.reactNavigationIntegration({
+ useDispatchedActionData: true,
+ });
+ ```
+
+### Fixes
+
+- Equalize TTID and TTFD duration when TTFD manual API is called and resolved before auto TTID ([#4680](https://github.com/getsentry/sentry-react-native/pull/4680))
+- Avoid loading Sentry native components in Expo Go ([#4696](https://github.com/getsentry/sentry-react-native/pull/4696))
+
+### Changes
+
+- Change `gradle.projectsEvaluated` to `project.afterEvaluate` in the Sentry Gradle Plugin to fix tasks not being created when using `--configure-on-demand` ([#4687](https://github.com/getsentry/sentry-react-native/pull/4687))
+- Remove `SENTRY_FORCE_FOREGROUND` from Xcode Scripts as the underlying `--force-foreground` Sentry CLI is no-op since v2.37.0 ([#4689](https://github.com/getsentry/sentry-react-native/pull/4689))
+- TTID and TTFD use native getters instead od events to pass timestamps to the JS layer ([#4669](https://github.com/getsentry/sentry-react-native/pull/4669), [#4681](https://github.com/getsentry/sentry-react-native/pull/4681))
+
+## 6.10.0
+
+### Features
+
+- Add thread information to spans ([#4579](https://github.com/getsentry/sentry-react-native/pull/4579))
+- Exposed `getDataFromUri` as a public API to retrieve data from a URI ([#4638](https://github.com/getsentry/sentry-react-native/pull/4638))
+- Add `enableExperimentalViewRenderer` to enable up to 5x times more performance in Session Replay on iOS ([#4660](https://github.com/getsentry/sentry-react-native/pull/4660))
+
+ ```js
+ import * as Sentry from '@sentry/react-native';
+
+ Sentry.init({
+ integrations: [
+ Sentry.mobileReplayIntegration({
+ enableExperimentalViewRenderer: true,
+ }),
+ ],
+ });
+ ```
+
+### Fixes
+
+- Considers the `SENTRY_DISABLE_AUTO_UPLOAD` and `SENTRY_DISABLE_NATIVE_DEBUG_UPLOAD` environment variables in the configuration of the Sentry Android Gradle Plugin for Expo plugin ([#4583](https://github.com/getsentry/sentry-react-native/pull/4583))
+- Handle non-string category in getCurrentScreen on iOS ([#4629](https://github.com/getsentry/sentry-react-native/pull/4629))
+- Use route name instead of route key for current route tracking ([#4650](https://github.com/getsentry/sentry-react-native/pull/4650))
+ - Using key caused user interaction transaction names to contain route hash in the name.
+
+### Dependencies
+
+- Bump Bundler Plugins from v3.2.0 to v3.2.2 ([#4585](https://github.com/getsentry/sentry-react-native/pull/4585), [#4620](https://github.com/getsentry/sentry-react-native/pull/4620))
+ - [changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md#322)
+ - [diff](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/3.2.0...3.2.2)
+- Bump CLI from v2.42.1 to v2.42.4 ([#4586](https://github.com/getsentry/sentry-react-native/pull/4586), [#4655](https://github.com/getsentry/sentry-react-native/pull/4655), [#4671](https://github.com/getsentry/sentry-react-native/pull/4671))
+ - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2424)
+ - [diff](https://github.com/getsentry/sentry-cli/compare/2.42.1...2.42.4)
+- Bump Cocoa SDK from v8.45.0 to v8.48.0 ([#4621](https://github.com/getsentry/sentry-react-native/pull/4621), [#4651](https://github.com/getsentry/sentry-react-native/pull/4651), [#4662](https://github.com/getsentry/sentry-react-native/pull/4662))
+ - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8480)
+ - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.45.0...8.48.0)
+- Bump Android SDK from v7.22.1 to v7.22.5 ([#4675](https://github.com/getsentry/sentry-react-native/pull/4675), [#4683](https://github.com/getsentry/sentry-react-native/pull/4683))
+ - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#7225)
+ - [diff](https://github.com/getsentry/sentry-java/compare/7.22.1...7.22.5)
+
+## 6.9.1
+
+### Fixes
+
+- Fixes missing Cold Start measurements by bumping the Android SDK version to v7.22.1 ([#4643](https://github.com/getsentry/sentry-react-native/pull/4643))
+- Attach App Start spans to the first created not the first processed root span ([#4618](https://github.com/getsentry/sentry-react-native/pull/4618), [#4644](https://github.com/getsentry/sentry-react-native/pull/4644))
+
+### Dependencies
+
+- Bump Android SDK from v7.22.0 to v7.22.1 ([#4643](https://github.com/getsentry/sentry-react-native/pull/4643))
+ - [changelog](https://github.com/getsentry/sentry-java/blob/7.x.x/CHANGELOG.md#7221)
+ - [diff](https://github.com/getsentry/sentry-java/compare/7.22.0...7.22.1)
+
+## 6.9.0
+
+> [!WARNING]
+> This release contains an issue where Cold starts can be incorrectly reported as Warm starts on Android. We recommend staying on version 6.4.0 if you use this feature on Android.
+> See issue [#4598](https://github.com/getsentry/sentry-react-native/issues/4598) for more details.
### Features
@@ -16,7 +290,7 @@
```js
import Sentry from "@sentry/react-native";
-
+
Sentry.showFeedbackWidget();
Sentry.wrap(RootComponent);
@@ -43,6 +317,10 @@
## 6.8.0
+> [!WARNING]
+> This release contains an issue where Cold starts can be incorrectly reported as Warm starts on Android. We recommend staying on version 6.4.0 if you use this feature on Android.
+> See issue [#4598](https://github.com/getsentry/sentry-react-native/issues/4598) for more details.
+
### Features
- Adds Sentry Android Gradle Plugin as an experimental Expo plugin feature ([#4440](https://github.com/getsentry/sentry-react-native/pull/4440))
@@ -143,6 +421,10 @@
## 6.7.0
+> [!WARNING]
+> This release contains an issue where Cold starts can be incorrectly reported as Warm starts on Android. We recommend staying on version 6.4.0 if you use this feature on Android.
+> See issue [#4598](https://github.com/getsentry/sentry-react-native/issues/4598) for more details.
+
### Features
- Add `ignoredComponents` option to `annotateReactComponents` to exclude specific components from React component annotations ([#4517](https://github.com/getsentry/sentry-react-native/pull/4517))
@@ -175,6 +457,10 @@
## 6.6.0
+> [!WARNING]
+> This release contains an issue where Cold starts can be incorrectly reported as Warm starts on Android. We recommend staying on version 6.4.0 if you use this feature on Android.
+> See issue [#4598](https://github.com/getsentry/sentry-react-native/issues/4598) for more details.
+
### Features
- Send Sentry React Native SDK version in the Session Replay Events on iOS ([#4450](https://github.com/getsentry/sentry-react-native/pull/4450))
@@ -211,6 +497,10 @@
## 6.5.0
+> [!WARNING]
+> This release contains an issue where Cold starts can be incorrectly reported as Warm starts on Android. We recommend staying on version 6.4.0 if you use this feature on Android.
+> See issue [#4598](https://github.com/getsentry/sentry-react-native/issues/4598) for more details.
+
### Features
- Mobile Session Replay is now generally available and ready for production use ([#4384](https://github.com/getsentry/sentry-react-native/pull/4384))
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8e67889ffa..ca64d58f7b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -12,7 +12,7 @@ This repository contains mono repository structure with multiple React Native an
# Requirements
-- nodejs 18 (with corepack enabled)
+- nodejs 18 (with corepack globally installed)
- yarn version specified in `package.json` (at the moment version 3.6)
## Building
diff --git a/dev-packages/e2e-tests/cli.mjs b/dev-packages/e2e-tests/cli.mjs
index cb910ef938..c9281549f3 100755
--- a/dev-packages/e2e-tests/cli.mjs
+++ b/dev-packages/e2e-tests/cli.mjs
@@ -234,16 +234,35 @@ if (actions.includes('test')) {
if (!sentryAuthToken) {
console.log('Skipping maestro test due to unavailable or empty SENTRY_AUTH_TOKEN');
} else {
- execSync(
- `maestro test maestro \
- --env=APP_ID="${appId}" \
- --env=SENTRY_AUTH_TOKEN="${sentryAuthToken}" \
- --debug-output maestro-logs \
- --flatten-debug-output`,
- {
- stdio: 'inherit',
- cwd: e2eDir,
- },
- );
+ try {
+ execSync(
+ `maestro test maestro \
+ --env=APP_ID="${appId}" \
+ --env=SENTRY_AUTH_TOKEN="${sentryAuthToken}" \
+ --debug-output maestro-logs \
+ --flatten-debug-output`,
+ {
+ stdio: 'inherit',
+ cwd: e2eDir,
+ },
+ );
+ } finally {
+ // Always redact sensitive data, even if the test fails
+ const redactScript = `
+ if [[ "$(uname)" == "Darwin" ]]; then
+ find ./maestro-logs -type f -exec sed -i '' "s/${sentryAuthToken}/[REDACTED]/g" {} +
+ echo 'Redacted sensitive data from logs on MacOS'
+ else
+ find ./maestro-logs -type f -exec sed -i "s/${sentryAuthToken}/[REDACTED]/g" {} +
+ echo 'Redacted sensitive data from logs on Ubuntu'
+ fi
+ `;
+
+ try {
+ execSync(redactScript, { stdio: 'inherit', cwd: e2eDir, shell: '/bin/bash' });
+ } catch (error) {
+ console.warn('Failed to redact sensitive data from logs:', error.message);
+ }
+ }
}
}
diff --git a/dev-packages/e2e-tests/maestro/feedback.yml b/dev-packages/e2e-tests/maestro/feedback.yml
new file mode 100644
index 0000000000..ce7d79b89d
--- /dev/null
+++ b/dev-packages/e2e-tests/maestro/feedback.yml
@@ -0,0 +1,19 @@
+appId: ${APP_ID}
+jsEngine: graaljs
+---
+- runFlow: utils/launchTestAppClear.yml
+
+
+# The following tests are happy path tests for the feedback widget on both iOS and Android.
+# They verify that the feedback form can be opened, filled out, and submitted successfully.
+# The tests are separate because iOS tests work better with `testID` and Android tests work better with `text`.
+
+- runFlow:
+ file: feedback/happyFlow-ios.yml
+ when:
+ platform: iOS
+
+- runFlow:
+ file: feedback/happyFlow-android.yml
+ when:
+ platform: Android
diff --git a/dev-packages/e2e-tests/maestro/feedback/happyFlow-android.yml b/dev-packages/e2e-tests/maestro/feedback/happyFlow-android.yml
new file mode 100644
index 0000000000..221b0cbf84
--- /dev/null
+++ b/dev-packages/e2e-tests/maestro/feedback/happyFlow-android.yml
@@ -0,0 +1,39 @@
+# This is a happy path test for the feedback widget on Android.
+# It verifies that the feedback form can be opened, filled out, and submitted successfully
+appId: ${APP_ID}
+jsEngine: graaljs
+---
+
+# Show feedback button
+- tapOn: 'Feedback'
+
+# Open feedback widget
+- tapOn: 'Report a Bug'
+
+# Assert that the feedback form is visible
+- extendedWaitUntil:
+ visible: 'Report a Bug'
+ timeout: 5_000
+
+# Fill out name field
+- tapOn: 'Your Name'
+- inputText: 'John Doe'
+
+# Fill out email field
+- tapOn: 'your.email@example.org'
+- inputText: 'test@email.com'
+
+# Fill out message field
+- tapOn: "What's the bug? What did you expect?"
+- inputText: 'This is a test feedback message from CI e2e tests'
+
+# Submit feedback
+- scrollUntilVisible:
+ element:
+ text: 'Send Bug Report'
+- tapOn: 'Send Bug Report'
+- assertVisible: 'Thank you for your report!'
+- tapOn: 'OK'
+
+# Verify feedback form is closed and the home screen is visible
+- assertVisible: 'Welcome to React Native'
diff --git a/dev-packages/e2e-tests/maestro/feedback/happyFlow-ios.yml b/dev-packages/e2e-tests/maestro/feedback/happyFlow-ios.yml
new file mode 100644
index 0000000000..7f8c3340b1
--- /dev/null
+++ b/dev-packages/e2e-tests/maestro/feedback/happyFlow-ios.yml
@@ -0,0 +1,45 @@
+# This is a happy path test for the feedback widget on iOS.
+# It verifies that the feedback form can be opened, filled out, and submitted successfully
+appId: ${APP_ID}
+jsEngine: graaljs
+---
+
+# Show feedback button
+- tapOn: 'Feedback'
+
+# Open feedback widget
+- tapOn:
+ id: 'sentry-feedback-button'
+
+# Assert that the feedback form is visible
+- extendedWaitUntil:
+ visible:
+ id: 'sentry-feedback-form-title'
+ timeout: 5_000
+
+# Fill out name field
+- tapOn:
+ id: 'sentry-feedback-name-input'
+- inputText: 'John Doe'
+
+# Fill out email field
+- tapOn:
+ id: 'sentry-feedback-email-input'
+- inputText: 'test@email.com'
+
+# Fill out message field
+- tapOn:
+ id: 'sentry-feedback-message-input'
+- inputText: 'This is a test feedback message from CI e2e tests'
+
+# Submit feedback
+- scrollUntilVisible:
+ element:
+ id: 'sentry-feedback-submit-button'
+- tapOn:
+ id: 'sentry-feedback-submit-button'
+- assertVisible: 'Thank you for your report!'
+- tapOn: 'OK'
+
+# Verify feedback form is closed and the home screen is visible
+- assertVisible: 'Welcome to React Native'
diff --git a/dev-packages/e2e-tests/maestro/utils/launchTestAppClear.yml b/dev-packages/e2e-tests/maestro/utils/launchTestAppClear.yml
index 09ec63166f..ad669cedcc 100644
--- a/dev-packages/e2e-tests/maestro/utils/launchTestAppClear.yml
+++ b/dev-packages/e2e-tests/maestro/utils/launchTestAppClear.yml
@@ -8,7 +8,6 @@ jsEngine: graaljs
- launchApp:
clearState: true
arguments:
- sentryAuthToken: ${SENTRY_AUTH_TOKEN}
replaysOnErrorSampleRate: ${replaysOnErrorSampleRate}
- runFlow: assertTestReady.yml
diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json
index 7f66e3884e..3c4fc4046e 100644
--- a/dev-packages/e2e-tests/package.json
+++ b/dev-packages/e2e-tests/package.json
@@ -1,6 +1,6 @@
{
"name": "sentry-react-native-e2e-tests",
- "version": "6.8.0",
+ "version": "6.15.1",
"private": true,
"description": "Sentry React Native End to End Tests Library",
"main": "dist/index.js",
@@ -14,7 +14,7 @@
"@babel/preset-env": "^7.25.3",
"@babel/preset-typescript": "^7.18.6",
"@sentry/core": "8.54.0",
- "@sentry/react-native": "6.8.0",
+ "@sentry/react-native": "6.15.1",
"@types/node": "^20.9.3",
"@types/react": "^18.2.64",
"appium": "2.4.1",
diff --git a/dev-packages/e2e-tests/patch-scripts/rn.patch.app.js b/dev-packages/e2e-tests/patch-scripts/rn.patch.app.js
index a670278da1..48660f1a56 100755
--- a/dev-packages/e2e-tests/patch-scripts/rn.patch.app.js
+++ b/dev-packages/e2e-tests/patch-scripts/rn.patch.app.js
@@ -38,7 +38,8 @@ Sentry.init({
const e2eComponentPatch = '';
const lastImportRex = /^([^]*)(import\s+[^;]*?;$)/m;
const patchRex = '@sentry/react-native';
-const headerComponentRex = //gm;
+const headerComponentRex = / m + initPatch)
- .replace(headerComponentRex, m => e2eComponentPatch + m);
+ .replace(headerComponentRex, m => e2eComponentPatch + m)
+ .replace(exportDefaultRex, 'export default Sentry.wrap(App);');
fs.writeFileSync(appPath, patched);
logger.info('Patched RN App.(js|tsx) successfully!');
diff --git a/dev-packages/e2e-tests/patch-scripts/rn.patch.xcode.js b/dev-packages/e2e-tests/patch-scripts/rn.patch.xcode.js
index f9be9e3329..d044817df2 100755
--- a/dev-packages/e2e-tests/patch-scripts/rn.patch.xcode.js
+++ b/dev-packages/e2e-tests/patch-scripts/rn.patch.xcode.js
@@ -32,7 +32,6 @@ if (semver.satisfies(args['rn-version'], `< ${newBundleScriptRNVersion}`, { incl
logger.info('Applying old bundle script patch');
bundleScript = `
export NODE_BINARY=node
-export SENTRY_CLI_EXTRA_ARGS="--force-foreground"
../node_modules/@sentry/react-native/scripts/sentry-xcode.sh ../node_modules/react-native/scripts/react-native-xcode.sh
`;
bundleScriptRegex = /(packager|scripts)\/react-native-xcode\.sh\b/;
@@ -40,7 +39,6 @@ export SENTRY_CLI_EXTRA_ARGS="--force-foreground"
} else if (semver.satisfies(args['rn-version'], `>= ${newBundleScriptRNVersion}`, { includePrerelease: true })) {
logger.info('Applying new bundle script patch');
bundleScript = `
-export SENTRY_CLI_EXTRA_ARGS="--force-foreground"
WITH_ENVIRONMENT="../node_modules/react-native/scripts/xcode/with-environment.sh"
REACT_NATIVE_XCODE="../node_modules/react-native/scripts/react-native-xcode.sh"
diff --git a/dev-packages/e2e-tests/src/EndToEndTests.tsx b/dev-packages/e2e-tests/src/EndToEndTests.tsx
index 173286e57b..bda3b591cf 100644
--- a/dev-packages/e2e-tests/src/EndToEndTests.tsx
+++ b/dev-packages/e2e-tests/src/EndToEndTests.tsx
@@ -1,28 +1,9 @@
import * as Sentry from '@sentry/react-native';
import * as React from 'react';
import { Text, View } from 'react-native';
-import { LaunchArguments } from "react-native-launch-arguments";
const E2E_TESTS_READY_TEXT = 'E2E Tests Ready';
-const getSentryAuthToken = ():
- | { token: string }
- | { error: string } => {
- const { sentryAuthToken } = LaunchArguments.value<{
- sentryAuthToken: unknown;
- }>();
-
- if (typeof sentryAuthToken !== 'string') {
- return { error: 'Sentry Auth Token is required' };
- }
-
- if (sentryAuthToken.length === 0) {
- return { error: 'Sentry Auth Token must not be empty' };
- }
-
- return { token: sentryAuthToken };
-};
-
const EndToEndTestsScreen = (): JSX.Element => {
const [isReady, setIsReady] = React.useState(false);
const [eventId, setEventId] = React.useState(null);
@@ -62,6 +43,11 @@ const EndToEndTestsScreen = (): JSX.Element => {
name: 'Unhandled Promise Rejection',
action: async () => await Promise.reject(new Error('Unhandled Promise Rejection')),
},
+ {
+ id: 'feedback',
+ name: 'Feedback',
+ action: () => Sentry.showFeedbackButton(),
+ },
{
id: 'close',
name: 'Close',
diff --git a/dev-packages/type-check/package.json b/dev-packages/type-check/package.json
index 929d8e8071..ca2c6623d7 100644
--- a/dev-packages/type-check/package.json
+++ b/dev-packages/type-check/package.json
@@ -1,7 +1,7 @@
{
"name": "sentry-react-native-type-check",
"private": true,
- "version": "6.8.0",
+ "version": "6.15.1",
"scripts": {
"type-check": "./run-type-check.sh"
}
diff --git a/dev-packages/type-check/run-type-check.sh b/dev-packages/type-check/run-type-check.sh
index 0060cd914b..041cf231e8 100755
--- a/dev-packages/type-check/run-type-check.sh
+++ b/dev-packages/type-check/run-type-check.sh
@@ -14,6 +14,9 @@ yalc add @sentry/react-native
yarn install
+echo "Removing duplicate React types..."
+rm -rf ./node_modules/@types/react-native/node_modules/@types/react
+
yarn type-check
rm yarn.lock
diff --git a/dev-packages/type-check/ts3.8-test/index.ts b/dev-packages/type-check/ts3.8-test/index.ts
index 6f1ec6d6cc..1e9fda3cd2 100644
--- a/dev-packages/type-check/ts3.8-test/index.ts
+++ b/dev-packages/type-check/ts3.8-test/index.ts
@@ -20,6 +20,11 @@ declare global {
}
interface PerformanceEntry {}
}
+
+declare module 'react-native' {
+ export interface TurboModule {}
+}
+
import 'react-native';
// we need to import the SDK to ensure tsc check the types
diff --git a/dev-packages/utils/package.json b/dev-packages/utils/package.json
index c16095f686..87c27ab3ec 100644
--- a/dev-packages/utils/package.json
+++ b/dev-packages/utils/package.json
@@ -1,11 +1,11 @@
{
"name": "sentry-react-native-samples-utils",
- "version": "6.8.0",
+ "version": "6.15.1",
"description": "Internal Samples Utils",
"main": "index.js",
"license": "MIT",
"private": true,
"dependencies": {
- "metro-config": "^0.81.0"
+ "metro-config": "^0.82.2"
}
}
diff --git a/lerna.json b/lerna.json
index 1dc40ce9bd..28dd6410f8 100644
--- a/lerna.json
+++ b/lerna.json
@@ -1,6 +1,6 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
- "version": "6.8.0",
+ "version": "6.15.1",
"packages": [
"packages/*",
"dev-packages/*",
diff --git a/package.json b/package.json
index 3253554fad..7af7287f13 100644
--- a/package.json
+++ b/package.json
@@ -30,7 +30,7 @@
"devDependencies": {
"@expo/swiftlint": "^0.57.1",
"@naturalcycles/ktlint": "^1.13.0",
- "@sentry/cli": "2.42.1",
+ "@sentry/cli": "2.46.0",
"clang-format": "^1.8.0",
"downlevel-dts": "^0.11.0",
"google-java-format": "^1.4.0",
diff --git a/packages/core/RNSentry.podspec b/packages/core/RNSentry.podspec
index f59ef9daad..d24d88d420 100644
--- a/packages/core/RNSentry.podspec
+++ b/packages/core/RNSentry.podspec
@@ -37,7 +37,7 @@ Pod::Spec.new do |s|
s.compiler_flags = other_cflags
- s.dependency 'Sentry/HybridSDK', '8.45.0'
+ s.dependency 'Sentry/HybridSDK', '8.52.1'
if defined? install_modules_dependencies
# Default React Native dependencies for 0.71 and above (new and legacy architecture)
diff --git a/packages/core/RNSentryAndroidTester/app/build.gradle b/packages/core/RNSentryAndroidTester/app/build.gradle
index fc0a2b9462..26334b0f85 100644
--- a/packages/core/RNSentryAndroidTester/app/build.gradle
+++ b/packages/core/RNSentryAndroidTester/app/build.gradle
@@ -43,9 +43,11 @@ dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.5.0'
+ implementation 'androidx.test:core-ktx:1.6.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.10.0'
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.2.1'
+ testImplementation 'org.robolectric:robolectric:4.14.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryAppStartTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryAppStartTest.kt
new file mode 100644
index 0000000000..009d300ce4
--- /dev/null
+++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryAppStartTest.kt
@@ -0,0 +1,159 @@
+package io.sentry.react
+
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.JavaOnlyMap
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.WritableMap
+import io.sentry.ILogger
+import io.sentry.SentryLevel
+import io.sentry.android.core.performance.AppStartMetrics
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.Captor
+import org.mockito.MockedStatic
+import org.mockito.Mockito.any
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.mockStatic
+import org.mockito.Mockito.verify
+import org.mockito.MockitoAnnotations
+import org.mockito.kotlin.clearInvocations
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class RNSentryAppStartTest {
+ private lateinit var module: RNSentryModuleImpl
+ private lateinit var promise: Promise
+ private lateinit var logger: ILogger
+ private lateinit var metrics: AppStartMetrics
+ private lateinit var metricsDataBag: Map
+
+ private var argumentsMock: MockedStatic? = null
+
+ @Captor
+ private lateinit var writableMapCaptor: ArgumentCaptor
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.openMocks(this)
+
+ promise = mock(Promise::class.java)
+ logger = mock(ILogger::class.java)
+
+ metrics = AppStartMetrics()
+ metrics.appStartTimeSpan.start()
+ metrics.appStartTimeSpan.stop()
+ metricsDataBag = mapOf()
+
+ RNSentryModuleImpl.lastStartTimestampMs = -1
+
+ module = Utils.createRNSentryModuleWithMockedContext()
+
+ // Mock the Arguments class
+ argumentsMock = mockStatic(Arguments::class.java)
+ whenever(Arguments.createMap()).thenReturn(JavaOnlyMap())
+ }
+
+ @After
+ fun tearDown() {
+ argumentsMock?.close()
+ }
+
+ @Test
+ fun `fetchNativeAppStart resolves promise with null when app is not launched in the foreground`() {
+ val metrics = AppStartMetrics()
+ metrics.isAppLaunchedInForeground = false
+
+ val metricsDataBag = mapOf()
+
+ module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger)
+
+ verifyWarnOnceWith(
+ logger,
+ "Invalid app start data: app not launched in foreground.",
+ )
+
+ verify(promise).resolve(null)
+ }
+
+ @Test
+ fun `fetchNativeAppStart resolves promise with app start data when app is launched in the foreground`() {
+ metrics.isAppLaunchedInForeground = true
+
+ module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger)
+
+ verifyDebugOnceWith(logger, "App Start data reported to the RN layer for the first time.")
+
+ val capturedMap = getWritableMapFromPromiseResolve(promise)
+ assertEquals(false, capturedMap.getBoolean("has_fetched"))
+ }
+
+ @Test
+ fun `fetchNativeAppStart marks data as fetched when retried multiple times`() {
+ metrics.isAppLaunchedInForeground = true
+
+ module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger)
+
+ // Clear invocations from the first call
+ clearInvocations(promise)
+ clearInvocations(logger)
+ module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger)
+
+ verifyDebugOnceWith(logger, "App Start data already fetched from native before.")
+
+ val capturedMap = getWritableMapFromPromiseResolve(promise)
+ assertEquals(true, capturedMap.getBoolean("has_fetched"))
+ }
+
+ @Test
+ fun `fetchNativeAppStart returns updated app start data as not fetched before`() {
+ metrics.isAppLaunchedInForeground = true
+
+ module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger)
+
+ // Clear invocations from the first call
+ clearInvocations(promise)
+ clearInvocations(logger)
+
+ metrics.onAppStartSpansSent()
+ metrics.appStartTimeSpan.setStartUnixTimeMs(1741691014000)
+ metrics.appStartTimeSpan.stop()
+ module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger)
+
+ verifyDebugOnceWith(logger, "App Start data updated, reporting to the RN layer again.")
+
+ val capturedMap = getWritableMapFromPromiseResolve(promise)
+ assertEquals(false, capturedMap.getBoolean("has_fetched"))
+ }
+
+ private fun getWritableMapFromPromiseResolve(promise: Promise): WritableMap {
+ verify(promise).resolve(any(WritableMap::class.java))
+ verify(promise).resolve(writableMapCaptor.capture())
+ return writableMapCaptor.value
+ }
+
+ private fun verifyWarnOnceWith(
+ logger: ILogger,
+ value: String,
+ ) {
+ verify(
+ logger,
+ org.mockito.kotlin.times(1),
+ ).log(eq(SentryLevel.WARNING), eq(value))
+ }
+
+ private fun verifyDebugOnceWith(
+ logger: ILogger,
+ value: String,
+ ) {
+ verify(
+ logger,
+ org.mockito.kotlin.times(1),
+ ).log(eq(SentryLevel.DEBUG), eq(value))
+ }
+}
diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt
deleted file mode 100644
index 34af996a76..0000000000
--- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt
+++ /dev/null
@@ -1,99 +0,0 @@
-package io.sentry.react
-
-import android.content.pm.PackageInfo
-import android.content.pm.PackageManager
-import com.facebook.react.bridge.Arguments
-import com.facebook.react.bridge.Promise
-import com.facebook.react.bridge.ReactApplicationContext
-import com.facebook.react.bridge.WritableMap
-import io.sentry.ILogger
-import io.sentry.SentryLevel
-import org.junit.After
-import org.junit.Assert.assertEquals
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.junit.runners.JUnit4
-import org.mockito.ArgumentCaptor
-import org.mockito.Captor
-import org.mockito.MockedStatic
-import org.mockito.Mockito.any
-import org.mockito.Mockito.anyInt
-import org.mockito.Mockito.anyString
-import org.mockito.Mockito.mock
-import org.mockito.Mockito.mockStatic
-import org.mockito.Mockito.verify
-import org.mockito.MockitoAnnotations
-import org.mockito.kotlin.whenever
-
-@RunWith(JUnit4::class)
-class RNSentryModuleImplTest {
- private lateinit var module: RNSentryModuleImpl
- private lateinit var promise: Promise
- private lateinit var logger: ILogger
- private var argumentsMock: MockedStatic? = null
-
- @Captor
- private lateinit var writableMapCaptor: ArgumentCaptor
-
- @Before
- fun setUp() {
- MockitoAnnotations.openMocks(this)
- val reactContext = mock(ReactApplicationContext::class.java)
- promise = mock(Promise::class.java)
- logger = mock(ILogger::class.java)
- val packageManager = mock(PackageManager::class.java)
- val packageInfo = mock(PackageInfo::class.java)
-
- whenever(reactContext.packageManager).thenReturn(packageManager)
- whenever(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo)
-
- module = RNSentryModuleImpl(reactContext)
-
- // Mock the Arguments class
- argumentsMock = mockStatic(Arguments::class.java)
- val writableMap = mock(WritableMap::class.java)
- whenever(Arguments.createMap()).thenReturn(writableMap)
- }
-
- @After
- fun tearDown() {
- argumentsMock?.close()
- }
-
- @Test
- fun `fetchNativeAppStart resolves promise with null when app is not launched in the foreground`() {
- // Mock the app start measurement
- val appStartMeasurement = mapOf()
-
- // Call the method
- module.fetchNativeAppStart(promise, appStartMeasurement, logger, false)
-
- // Verify a warning log is emitted
- verify(logger, org.mockito.kotlin.times(1)).log(
- SentryLevel.WARNING,
- "Invalid app start data: app not launched in foreground.",
- )
-
- // Verify the promise is resolved with null
- verify(promise).resolve(null)
- }
-
- @Test
- fun `fetchNativeAppStart resolves promise with app start data when app is launched in the foreground`() {
- // Mock the app start measurement
- val appStartMeasurement = mapOf()
-
- // Call the method
- module.fetchNativeAppStart(promise, appStartMeasurement, logger, true)
-
- // Verify no logs are emitted
- verify(logger, org.mockito.kotlin.times(0)).log(any(), any())
-
- // Verify the promise is resolved with the expected data
- verify(promise).resolve(any(WritableMap::class.java))
- verify(promise).resolve(writableMapCaptor.capture())
- val capturedMap = writableMapCaptor.value
- assertEquals(false, capturedMap.getBoolean("has_fetched"))
- }
-}
diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleInitWithApplicationTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleInitWithApplicationTest.kt
new file mode 100644
index 0000000000..7585335309
--- /dev/null
+++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleInitWithApplicationTest.kt
@@ -0,0 +1,29 @@
+package io.sentry.react
+
+import android.app.Application
+import org.junit.Assert.assertSame
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class RNSentryModuleInitWithApplicationTest {
+ @Test
+ fun `when application context is null fallback to react context`() {
+ val mockedReactContext = Utils.makeReactContextMock()
+ whenever(mockedReactContext.applicationContext).thenReturn(null)
+
+ assertSame(RNSentryModuleImpl(mockedReactContext).applicationContext, mockedReactContext)
+ }
+
+ @Test
+ fun `use application context if available`() {
+ val mockedApplicationContext = mock(Application::class.java)
+ val mockedReactContext = Utils.makeReactContextMock()
+ whenever(mockedReactContext.applicationContext).thenReturn(mockedApplicationContext)
+
+ assertSame(RNSentryModuleImpl(mockedReactContext).applicationContext, mockedApplicationContext)
+ }
+}
diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryOnDrawReporterTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryOnDrawReporterTest.kt
new file mode 100644
index 0000000000..183895561d
--- /dev/null
+++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryOnDrawReporterTest.kt
@@ -0,0 +1,166 @@
+package io.sentry.react
+
+import android.app.Activity
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import com.facebook.react.bridge.ReactApplicationContext
+import io.sentry.android.core.BuildInfoProvider
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.MockitoAnnotations
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class RNSentryOnDrawReporterTest {
+ companion object {
+ private const val TTID_PREFIX = RNSentryOnDrawReporterManager.TTID_PREFIX
+ private const val TTFD_PREFIX = RNSentryOnDrawReporterManager.TTFD_PREFIX
+ private const val SPAN_ID = "test-span-id"
+ private const val NEW_SPAN_ID = "new-test-span-id"
+ }
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.openMocks(this)
+ }
+
+ @Test
+ fun `when parentSpanId and timeToFullDisplay are set the next render timestamp is saved`() {
+ val reporter = createTestRNSentryOnDrawReporterView()
+ reporter.setFullDisplay(true)
+ reporter.setParentSpanId(SPAN_ID)
+
+ assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID))
+ }
+
+ @Test
+ fun `when parentSpanId and timeToInitialDisplay are set the next render timestamp is saved`() {
+ val reporter = createTestRNSentryOnDrawReporterView()
+ reporter.setInitialDisplay(true)
+ reporter.setParentSpanId(SPAN_ID)
+
+ assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID))
+ }
+
+ @Test
+ fun `when parentSpanId and timeToFullDisplay are set the next render timestamp is saved - reversed order`() {
+ val reporter = createTestRNSentryOnDrawReporterView()
+ reporter.setParentSpanId(SPAN_ID)
+ reporter.setFullDisplay(true)
+
+ assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID))
+ }
+
+ @Test
+ fun `when parentSpanId and timeToInitialDisplay are set the next render timestamp is saved - reversed order`() {
+ val reporter = createTestRNSentryOnDrawReporterView()
+ reporter.setParentSpanId(SPAN_ID)
+ reporter.setInitialDisplay(true)
+
+ assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID))
+ }
+
+ @Test
+ fun `when display flag and parentSpanId changes the next full display render is saved`() {
+ val reporter = createTestRNSentryOnDrawReporterView()
+ reporter.setFullDisplay(true)
+ reporter.setParentSpanId(SPAN_ID)
+ RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID)
+
+ reporter.setFullDisplay(false)
+ reporter.setFullDisplay(true)
+ reporter.setParentSpanId(NEW_SPAN_ID)
+ assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + NEW_SPAN_ID))
+ }
+
+ @Test
+ fun `when display flag and parentSpanId changes the next initial display render is saved`() {
+ val reporter = createTestRNSentryOnDrawReporterView()
+ reporter.setInitialDisplay(true)
+ reporter.setParentSpanId(SPAN_ID)
+ RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID)
+
+ reporter.setInitialDisplay(false)
+ reporter.setInitialDisplay(true)
+ reporter.setParentSpanId(NEW_SPAN_ID)
+ assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + NEW_SPAN_ID))
+ }
+
+ @Test
+ fun `when parentSpanId doesn't change the next full display render is not saved`() {
+ val reporter = createTestRNSentryOnDrawReporterView()
+ reporter.setFullDisplay(true)
+ reporter.setParentSpanId(SPAN_ID)
+ RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID)
+
+ reporter.setFullDisplay(false)
+ reporter.setFullDisplay(true)
+ reporter.setParentSpanId(SPAN_ID)
+ assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID))
+ }
+
+ @Test
+ fun `when parentSpanId doesn't change the next initial display render is not saved`() {
+ val reporter = createTestRNSentryOnDrawReporterView()
+ reporter.setInitialDisplay(true)
+ reporter.setParentSpanId(SPAN_ID)
+ RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID)
+
+ reporter.setInitialDisplay(false)
+ reporter.setInitialDisplay(true)
+ reporter.setParentSpanId(SPAN_ID)
+ assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID))
+ }
+
+ @Test
+ fun `when display flag doesn't change the next full display render is not saved`() {
+ val reporter = createTestRNSentryOnDrawReporterView()
+ reporter.setFullDisplay(true)
+ reporter.setParentSpanId(SPAN_ID)
+ RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID)
+
+ reporter.setFullDisplay(true)
+ assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID))
+ }
+
+ @Test
+ fun `when display flag doesn't change the next initial display render is not saved`() {
+ val reporter = createTestRNSentryOnDrawReporterView()
+ reporter.setInitialDisplay(true)
+ reporter.setParentSpanId(SPAN_ID)
+ RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID)
+
+ reporter.setInitialDisplay(true)
+ assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID))
+ }
+
+ class TestRNSentryOnDrawReporterView(
+ context: Context,
+ reactContext: ReactApplicationContext,
+ buildInfo: BuildInfoProvider,
+ ) : RNSentryOnDrawReporterManager.RNSentryOnDrawReporterView(context, reactContext, buildInfo) {
+ override fun registerForNextDraw(
+ activity: Activity,
+ callback: Runnable,
+ buildInfo: BuildInfoProvider,
+ ) {
+ callback.run()
+ }
+ }
+
+ private fun createTestRNSentryOnDrawReporterView(): TestRNSentryOnDrawReporterView =
+ TestRNSentryOnDrawReporterView(ApplicationProvider.getApplicationContext(), mockReactContext(), mockBuildInfo())
+
+ private fun mockReactContext(): ReactApplicationContext {
+ val reactContext = mock()
+ whenever(reactContext.getCurrentActivity()).thenReturn(mock())
+ return reactContext
+ }
+
+ private fun mockBuildInfo(): BuildInfoProvider = mock()
+}
diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryTimeToDisplayTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryTimeToDisplayTest.kt
new file mode 100644
index 0000000000..5ec953b287
--- /dev/null
+++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryTimeToDisplayTest.kt
@@ -0,0 +1,48 @@
+package io.sentry.react
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class RNSentryTimeToDisplayTest {
+ companion object {
+ val TEST_ID = "test-id"
+ val TEST_VAL = 123.4
+ }
+
+ @Before
+ fun setUp() {
+ }
+
+ @Test
+ fun `puts and pops record`() {
+ RNSentryTimeToDisplay.putTimeToDisplayFor(TEST_ID, TEST_VAL)
+
+ val firstPop = RNSentryTimeToDisplay.popTimeToDisplayFor(TEST_ID)
+ val secondPop = RNSentryTimeToDisplay.popTimeToDisplayFor(TEST_ID)
+
+ assertEquals(firstPop, TEST_VAL, 0.0)
+ assertNull(secondPop)
+ }
+
+ @Test
+ fun `removes oldes entry when full`() {
+ val maxSize = RNSentryTimeToDisplay.ENTRIES_MAX_SIZE + 1
+ for (i in 1..maxSize) {
+ RNSentryTimeToDisplay.putTimeToDisplayFor("$TEST_ID-$i", i.toDouble())
+ }
+
+ val oldestEntry = RNSentryTimeToDisplay.popTimeToDisplayFor("$TEST_ID-1")
+ val secondOldestEntry = RNSentryTimeToDisplay.popTimeToDisplayFor("$TEST_ID-2")
+ val newestEntry = RNSentryTimeToDisplay.popTimeToDisplayFor("$TEST_ID-$maxSize")
+
+ assertNull(oldestEntry)
+ assertNotNull(secondOldestEntry)
+ assertNotNull(newestEntry)
+ }
+}
diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/Utils.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/Utils.kt
new file mode 100644
index 0000000000..840e940917
--- /dev/null
+++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/Utils.kt
@@ -0,0 +1,30 @@
+package io.sentry.react
+
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import com.facebook.react.bridge.ReactApplicationContext
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.ArgumentMatchers.anyString
+import org.mockito.Mockito.mock
+import org.mockito.kotlin.whenever
+
+class Utils {
+ companion object {
+ fun makeReactContextMock(): ReactApplicationContext {
+ val packageManager = mock(PackageManager::class.java)
+ val packageInfo = mock(PackageInfo::class.java)
+
+ val reactContext = mock(ReactApplicationContext::class.java)
+ whenever(reactContext.packageManager).thenReturn(packageManager)
+ whenever(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo)
+
+ return reactContext
+ }
+
+ fun createRNSentryModuleWithMockedContext(): RNSentryModuleImpl {
+ RNSentryModuleImpl.lastStartTimestampMs = -1
+
+ return RNSentryModuleImpl(makeReactContextMock())
+ }
+ }
+}
diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/utils/RNSentryActivityUtilsTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/utils/RNSentryActivityUtilsTest.kt
new file mode 100644
index 0000000000..3887b9991d
--- /dev/null
+++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/utils/RNSentryActivityUtilsTest.kt
@@ -0,0 +1,52 @@
+package io.sentry.react.utils
+
+import android.app.Activity
+import com.facebook.react.bridge.ReactApplicationContext
+import io.sentry.android.core.AndroidLogger
+import io.sentry.android.core.CurrentActivityHolder
+import org.junit.After
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.mockito.Mockito.mock
+import org.mockito.kotlin.whenever
+
+@RunWith(JUnit4::class)
+class RNSentryActivityUtilsTest {
+ private val mockedLogger = mock(AndroidLogger::class.java)
+
+ @After
+ fun clearActivityHolder() {
+ CurrentActivityHolder.getInstance().clearActivity()
+ }
+
+ @Test
+ fun `returns react context activity`() {
+ val mockedCurrentActivity = mock(Activity::class.java)
+ val mockedReactContext = mock(ReactApplicationContext::class.java)
+ whenever(mockedReactContext.currentActivity).thenReturn(mockedCurrentActivity)
+
+ assertSame(RNSentryActivityUtils.getCurrentActivity(mockedReactContext, mockedLogger), mockedCurrentActivity)
+ }
+
+ @Test
+ fun `returns current activity holder activity`() {
+ val mockedCurrentActivity = mock(Activity::class.java)
+
+ val mockedReactContext = mock(ReactApplicationContext::class.java)
+ whenever(mockedReactContext.currentActivity).thenReturn(null)
+
+ CurrentActivityHolder.getInstance().setActivity(mockedCurrentActivity)
+ assertSame(RNSentryActivityUtils.getCurrentActivity(mockedReactContext, mockedLogger), mockedCurrentActivity)
+ }
+
+ @Test
+ fun `returns null when no activity exists`() {
+ val mockedReactContext = mock(ReactApplicationContext::class.java)
+ whenever(mockedReactContext.currentActivity).thenReturn(null)
+
+ assertNull(RNSentryActivityUtils.getCurrentActivity(mockedReactContext, mockedLogger))
+ }
+}
diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj
index d922dce766..c9297fbfd1 100644
--- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj
+++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj
@@ -19,6 +19,9 @@
339C6C4B2D3FD9B200CA72ED /* valid.options.json in Resources */ = {isa = PBXBuildFile; fileRef = 339C6C4A2D3FD9AB00CA72ED /* valid.options.json */; };
33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */; };
33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */; };
+ 33DEDFEA2D8DBE67006066E4 /* RNSentryOnDrawReporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFE92D8DBE5B006066E4 /* RNSentryOnDrawReporterTests.swift */; };
+ 33DEDFED2D8DC825006066E4 /* RNSentryOnDrawReporter+Test.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFEC2D8DC820006066E4 /* RNSentryOnDrawReporter+Test.mm */; };
+ 33DEDFF02D9185EB006066E4 /* RNSentryTimeToDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFEF2D9185E3006066E4 /* RNSentryTimeToDisplayTests.swift */; };
33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33F58ACF2977037D008F60EA /* RNSentryTests.mm */; };
AEFB00422CC90C4B00EC8A9A /* RNSentryBreadcrumbTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */; };
B5859A50A3E865EF5E61465A /* libPods-RNSentryCocoaTesterTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 650CB718ACFBD05609BF2126 /* libPods-RNSentryCocoaTesterTests.a */; };
@@ -58,6 +61,11 @@
33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryDependencyContainerTests.m; sourceTree = ""; };
33AFDFF22B8D15F600AAB120 /* RNSentryDependencyContainerTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNSentryDependencyContainerTests.h; sourceTree = ""; };
33AFE0132B8F31AF00AAB120 /* RNSentryDependencyContainer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryDependencyContainer.h; path = ../ios/RNSentryDependencyContainer.h; sourceTree = ""; };
+ 33DEDFE92D8DBE5B006066E4 /* RNSentryOnDrawReporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryOnDrawReporterTests.swift; sourceTree = ""; };
+ 33DEDFEB2D8DC800006066E4 /* RNSentryOnDrawReporter+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentryOnDrawReporter+Test.h"; sourceTree = ""; };
+ 33DEDFEC2D8DC820006066E4 /* RNSentryOnDrawReporter+Test.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = "RNSentryOnDrawReporter+Test.mm"; sourceTree = ""; };
+ 33DEDFEE2D8DD431006066E4 /* RNSentryTimeToDisplay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryTimeToDisplay.h; path = ../ios/RNSentryTimeToDisplay.h; sourceTree = SOURCE_ROOT; };
+ 33DEDFEF2D9185E3006066E4 /* RNSentryTimeToDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryTimeToDisplayTests.swift; sourceTree = ""; };
33F58ACF2977037D008F60EA /* RNSentryTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RNSentryTests.mm; sourceTree = ""; };
650CB718ACFBD05609BF2126 /* libPods-RNSentryCocoaTesterTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RNSentryCocoaTesterTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
E2321E7CFA55AB617247098E /* Pods-RNSentryCocoaTesterTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNSentryCocoaTesterTests.debug.xcconfig"; path = "Target Support Files/Pods-RNSentryCocoaTesterTests/Pods-RNSentryCocoaTesterTests.debug.xcconfig"; sourceTree = ""; };
@@ -109,6 +117,10 @@
children = (
339C6C412D3FD39500CA72ED /* RNSentryStartFromFileTests.swift */,
339C6C3B2D3EB23B00CA72ED /* RNSentryStartTests.swift */,
+ 33DEDFEF2D9185E3006066E4 /* RNSentryTimeToDisplayTests.swift */,
+ 33DEDFEC2D8DC820006066E4 /* RNSentryOnDrawReporter+Test.mm */,
+ 33DEDFEB2D8DC800006066E4 /* RNSentryOnDrawReporter+Test.h */,
+ 33DEDFE92D8DBE5B006066E4 /* RNSentryOnDrawReporterTests.swift */,
3339C47F2D6625260088EB3A /* RNSentry+Test.h */,
332D334A2CDCC8EB00547D76 /* RNSentryCocoaTesterTests-Bridging-Header.h */,
332D33492CDCC8E100547D76 /* RNSentryTests.h */,
@@ -119,7 +131,6 @@
33AFDFEE2B8D14C200AAB120 /* RNSentryFramesTrackerListenerTests.h */,
33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */,
33AFDFF22B8D15F600AAB120 /* RNSentryDependencyContainerTests.h */,
- 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */,
3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */,
332D33462CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift */,
3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */,
@@ -154,6 +165,7 @@
333B58AF2D36A7FD000F8D04 /* RNSentrySDK.h */,
333B58A92D35BB2D000F8D04 /* RNSentryStart+Test.h */,
333B58A82D35BA93000F8D04 /* RNSentryStart.h */,
+ 33DEDFEE2D8DD431006066E4 /* RNSentryTimeToDisplay.h */,
3380C6C02CDEC56B0018B9B6 /* Replay */,
332D33482CDBDC7300547D76 /* RNSentry.h */,
3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */,
@@ -292,12 +304,14 @@
AEFB00422CC90C4B00EC8A9A /* RNSentryBreadcrumbTests.swift in Sources */,
332D33472CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift in Sources */,
339C6C3C2D3EB25100CA72ED /* RNSentryStartTests.swift in Sources */,
+ 33DEDFEA2D8DBE67006066E4 /* RNSentryOnDrawReporterTests.swift in Sources */,
33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */,
339C6C422D3FD3AE00CA72ED /* RNSentryStartFromFileTests.swift in Sources */,
336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */,
+ 33DEDFED2D8DC825006066E4 /* RNSentryOnDrawReporter+Test.mm in Sources */,
33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */,
- 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */,
3339C4812D6625570088EB3A /* RNSentryUserTests.mm in Sources */,
+ 33DEDFF02D9185EB006066E4 /* RNSentryTimeToDisplayTests.swift in Sources */,
3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */,
33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */,
);
diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryBreadcrumbTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryBreadcrumbTests.swift
index eb314766e8..0002c11e4f 100644
--- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryBreadcrumbTests.swift
+++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryBreadcrumbTests.swift
@@ -54,6 +54,12 @@ final class RNSentryBreadcrumbTests: XCTestCase {
XCTAssertNil(actual)
}
+ func testNullForNonStringCategory() {
+ let map: [String: Any] = ["category": false]
+ let actual = RNSentryBreadcrumb.getCurrentScreen(from: map)
+ XCTAssertNil(actual)
+ }
+
func testNullForNonNavigationCategory() {
let map: [String: Any] = ["category": "unknown"]
let actual = RNSentryBreadcrumb.getCurrentScreen(from: map)
diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h
index 08fddcbf8e..da0eb2b587 100644
--- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h
+++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h
@@ -3,10 +3,12 @@
//
#import "RNSentryBreadcrumb.h"
+#import "RNSentryOnDrawReporter+Test.h"
#import "RNSentryReplay.h"
#import "RNSentryReplayBreadcrumbConverter.h"
#import "RNSentryReplayMask.h"
#import "RNSentryReplayUnmask.h"
#import "RNSentrySDK+Test.h"
#import "RNSentryStart.h"
+#import "RNSentryTimeToDisplay.h"
#import "RNSentryVersion.h"
diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.h
new file mode 100644
index 0000000000..2ef701d215
--- /dev/null
+++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.h
@@ -0,0 +1,10 @@
+#import "RNSentryOnDrawReporter.h"
+#import
+
+@interface
+RNSentryOnDrawReporterView (Testing)
+
++ (instancetype)createWithMockedListener;
+- (RNSentryEmitNewFrameEvent)createEmitNewFrameEvent;
+
+@end
diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.mm b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.mm
new file mode 100644
index 0000000000..3aca532855
--- /dev/null
+++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.mm
@@ -0,0 +1,49 @@
+#import "RNSentryOnDrawReporter+Test.h"
+
+@interface MockedListener : NSObject
+@property (strong, nonatomic) RNSentryEmitNewFrameEvent emitNewFrameEvent;
+- (instancetype)initWithMockedListener:(RNSentryEmitNewFrameEvent)emitNewFrameEvent;
+@end
+
+@implementation MockedListener
+
+- (instancetype)initWithMockedListener:(RNSentryEmitNewFrameEvent)emitNewFrameEvent
+{
+ self = [super init];
+ if (self) {
+ _emitNewFrameEvent = [emitNewFrameEvent copy];
+ }
+ return self;
+}
+
+- (void)startListening
+{
+ self.emitNewFrameEvent(@([[NSDate date] timeIntervalSince1970]));
+}
+
+- (void)framesTrackerHasNewFrame:(nonnull NSDate *)newFrameDate
+{
+ NSLog(@"Not implemented in the test mock");
+}
+
+@end
+
+@implementation
+RNSentryOnDrawReporterView (Testing)
+
++ (instancetype)createWithMockedListener
+{
+ return [[self alloc] initWithMockedListener];
+}
+
+- (instancetype)initWithMockedListener
+{
+ self = [super init];
+ if (self) {
+ self.framesListener =
+ [[MockedListener alloc] initWithMockedListener:[self createEmitNewFrameEvent]];
+ }
+ return self;
+}
+
+@end
diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.m b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.m
deleted file mode 100644
index 13de6a12c9..0000000000
--- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.m
+++ /dev/null
@@ -1,16 +0,0 @@
-#import "RNSentryOnDrawReporter.h"
-#import
-
-@interface RNSentryOnDrawReporterTests : XCTestCase
-
-@end
-
-@implementation RNSentryOnDrawReporterTests
-
-- (void)testRNSentryOnDrawReporterViewIsAvailableWhenUIKitIs
-{
- RNSentryOnDrawReporterView *view = [[RNSentryOnDrawReporterView alloc] init];
- XCTAssertNotNil(view);
-}
-
-@end
diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.swift
new file mode 100644
index 0000000000..314ee57dbe
--- /dev/null
+++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.swift
@@ -0,0 +1,148 @@
+import Sentry
+import XCTest
+
+final class RNSentryOnDrawReporterTests: XCTestCase {
+ private let ttidPrefix = "ttid-"
+ private let ttfdPrefix = "ttfd-"
+ private let spanId = "test-span-id"
+ private let newSpanId = "new-test-span-id"
+
+ func testRNSentryOnDrawReporterViewIsAvailableWhenUIKitIs() {
+ let view = RNSentryOnDrawReporterView()
+ XCTAssertNotNil(view)
+ }
+
+ func testWhenParentSpanIdAndTimeToFullDisplayAreSetTheNextRenderTimestampIsSaved() {
+ let reporter = RNSentryOnDrawReporterView.createWithMockedListener()
+ reporter!.fullDisplay = true
+ reporter!.parentSpanId = spanId
+ reporter!.didSetProps(["fullDisplay", "parentSpanId"])
+
+ XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId))
+ }
+
+ func testWhenParentSpanIdAndTimeToInitialDisplayAreSetTheNextRenderTimestampIsSaved() {
+ let reporter = RNSentryOnDrawReporterView.createWithMockedListener()
+ reporter!.initialDisplay = true
+ reporter!.parentSpanId = spanId
+ reporter!.didSetProps(["initialDisplay", "parentSpanId"])
+
+ XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId))
+ }
+
+ func testWhenDisplayFlagAndParentSpanIdChangesTheNextFullDisplayRenderIsSaved() {
+ let reporter = RNSentryOnDrawReporterView.createWithMockedListener()
+ reporter!.fullDisplay = true
+ reporter!.parentSpanId = spanId
+ reporter!.didSetProps(["fullDisplay", "parentSpanId"])
+ RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId)
+
+ reporter!.fullDisplay = false
+ reporter!.didSetProps(["fullDisplay"])
+ reporter!.fullDisplay = true
+ reporter!.parentSpanId = newSpanId
+ reporter!.didSetProps(["fullDisplay", "parentSpanId"])
+
+ XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + newSpanId))
+ }
+
+ func testWhenDisplayFlagAndParentSpanIdChangesTheNextInitialDisplayRenderIsSaved() {
+ let reporter = RNSentryOnDrawReporterView.createWithMockedListener()
+ reporter!.initialDisplay = true
+ reporter!.parentSpanId = spanId
+ reporter!.didSetProps(["initialDisplay", "parentSpanId"])
+ RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId)
+
+ reporter!.initialDisplay = false
+ reporter!.didSetProps(["initalDisplay"])
+ reporter!.initialDisplay = true
+ reporter!.parentSpanId = newSpanId
+ reporter!.didSetProps(["initialDisplay", "parentSpanId"])
+
+ XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + newSpanId))
+ }
+
+ func testWhenParentSpanIdDoesntChangeTheNextFullDisplayRenderIsNotSaved() {
+ let reporter = RNSentryOnDrawReporterView.createWithMockedListener()
+ reporter!.fullDisplay = true
+ reporter!.parentSpanId = spanId
+ reporter!.didSetProps(["fullDisplay", "parentSpanId"])
+ RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId)
+
+ reporter!.fullDisplay = false
+ reporter!.didSetProps(["fullDisplay"])
+ reporter!.fullDisplay = true
+ reporter!.parentSpanId = spanId
+ reporter!.didSetProps(["fullDisplay", "parentSpanId"])
+
+ XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId))
+ }
+
+ func testWhenParentSpanIdDoesntChangeTheNextInitialDisplayRenderIsNotSaved() {
+ let reporter = RNSentryOnDrawReporterView.createWithMockedListener()
+ reporter!.initialDisplay = true
+ reporter!.parentSpanId = spanId
+ reporter!.didSetProps(["initialDisplay", "parentSpanId"])
+ RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId)
+
+ reporter!.initialDisplay = false
+ reporter!.didSetProps(["initalDisplay"])
+ reporter!.initialDisplay = true
+ reporter!.parentSpanId = spanId
+ reporter!.didSetProps(["initialDisplay", "parentSpanId"])
+
+ XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId))
+ }
+
+ func testWhenDisplayFlagDoesntChangeTheNextFullDisplayRenderIsNotSaved() {
+ let reporter = RNSentryOnDrawReporterView.createWithMockedListener()
+ reporter!.fullDisplay = true
+ reporter!.parentSpanId = spanId
+ reporter!.didSetProps(["fullDisplay", "parentSpanId"])
+ RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId)
+
+ reporter!.fullDisplay = true
+ reporter!.didSetProps(["fullDisplay", "parentSpanId"])
+
+ XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId))
+ }
+
+ func testWhenDisplayFlagDoesntChangeTheNextInitialDisplayRenderIsNotSaved() {
+ let reporter = RNSentryOnDrawReporterView.createWithMockedListener()
+ reporter!.initialDisplay = true
+ reporter!.parentSpanId = spanId
+ reporter!.didSetProps(["initialDisplay", "parentSpanId"])
+ RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId)
+
+ reporter!.initialDisplay = true
+ reporter!.didSetProps(["initialDisplay", "parentSpanId"])
+
+ XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId))
+ }
+
+ func testFullDisplayEmitNewFrameCallbackHandlesMissingParentSpanId() {
+ let reporter = RNSentryOnDrawReporterView.createWithMockedListener()
+
+ // We call the callback manually in this test to simulate a change between
+ // start of listening for next frame and the next frame render
+ let emitNewFrameCallback = reporter!.createEmitNewFrameEvent()
+
+ reporter!.fullDisplay = true
+ reporter!.parentSpanId = nil
+
+ emitNewFrameCallback!(1)
+ }
+
+ func testInitialDisplayEmitNewFrameCallbackHandlesMissingParentSpanId() {
+ let reporter = RNSentryOnDrawReporterView.createWithMockedListener()
+
+ // We call the callback manually in this test to simulate a change between
+ // start of listening for next frame and the next frame render
+ let emitNewFrameCallback = reporter!.createEmitNewFrameEvent()
+
+ reporter!.initialDisplay = true
+ reporter!.parentSpanId = nil
+
+ emitNewFrameCallback!(1)
+ }
+}
diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift
index 0cbe2cefdf..200d6422ec 100644
--- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift
+++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift
@@ -48,13 +48,15 @@ final class RNSentryReplayOptions: XCTestCase {
}
func assertAllDefaultReplayOptionsAreNotNil(replayOptions: [String: Any]) {
- XCTAssertEqual(replayOptions.count, 6)
+ XCTAssertEqual(replayOptions.count, 8)
XCTAssertNotNil(replayOptions["sessionSampleRate"])
XCTAssertNotNil(replayOptions["errorSampleRate"])
XCTAssertNotNil(replayOptions["maskAllImages"])
XCTAssertNotNil(replayOptions["maskAllText"])
XCTAssertNotNil(replayOptions["maskedViewClasses"])
XCTAssertNotNil(replayOptions["sdkInfo"])
+ XCTAssertNotNil(replayOptions["enableViewRendererV2"])
+ XCTAssertNotNil(replayOptions["enableFastViewRendering"])
}
func testSessionSampleRate() {
@@ -165,4 +167,86 @@ final class RNSentryReplayOptions: XCTestCase {
XCTAssertEqual(actualOptions.sessionReplay.maskedViewClasses.count, 0)
}
+ func testEnableViewRendererV2Default() {
+ let optionsDict = ([
+ "dsn": "https://abc@def.ingest.sentry.io/1234567",
+ "replaysOnErrorSampleRate": 0.75
+ ] as NSDictionary).mutableCopy() as! NSMutableDictionary
+
+ RNSentryReplay.updateOptions(optionsDict)
+
+ let actualOptions = try! Options(dict: optionsDict as! [String: Any])
+
+ XCTAssertTrue(actualOptions.sessionReplay.enableViewRendererV2)
+ }
+
+ func testEnableViewRendererV2True() {
+ let optionsDict = ([
+ "dsn": "https://abc@def.ingest.sentry.io/1234567",
+ "replaysOnErrorSampleRate": 0.75,
+ "mobileReplayOptions": [ "enableViewRendererV2": true ]
+ ] as NSDictionary).mutableCopy() as! NSMutableDictionary
+
+ RNSentryReplay.updateOptions(optionsDict)
+
+ let actualOptions = try! Options(dict: optionsDict as! [String: Any])
+
+ XCTAssertTrue(actualOptions.sessionReplay.enableViewRendererV2)
+ }
+
+ func testEnableViewRendererV2False() {
+ let optionsDict = ([
+ "dsn": "https://abc@def.ingest.sentry.io/1234567",
+ "replaysOnErrorSampleRate": 0.75,
+ "mobileReplayOptions": [ "enableViewRendererV2": false ]
+ ] as NSDictionary).mutableCopy() as! NSMutableDictionary
+
+ RNSentryReplay.updateOptions(optionsDict)
+
+ let actualOptions = try! Options(dict: optionsDict as! [String: Any])
+
+ XCTAssertFalse(actualOptions.sessionReplay.enableViewRendererV2)
+ }
+
+ func testEnableFastViewRenderingDefault() {
+ let optionsDict = ([
+ "dsn": "https://abc@def.ingest.sentry.io/1234567",
+ "replaysOnErrorSampleRate": 0.75
+ ] as NSDictionary).mutableCopy() as! NSMutableDictionary
+
+ RNSentryReplay.updateOptions(optionsDict)
+
+ let actualOptions = try! Options(dict: optionsDict as! [String: Any])
+
+ XCTAssertFalse(actualOptions.sessionReplay.enableFastViewRendering)
+ }
+
+ func testEnableFastViewRenderingTrue() {
+ let optionsDict = ([
+ "dsn": "https://abc@def.ingest.sentry.io/1234567",
+ "replaysOnErrorSampleRate": 0.75,
+ "mobileReplayOptions": [ "enableFastViewRendering": true ]
+ ] as NSDictionary).mutableCopy() as! NSMutableDictionary
+
+ RNSentryReplay.updateOptions(optionsDict)
+
+ let actualOptions = try! Options(dict: optionsDict as! [String: Any])
+
+ XCTAssertTrue(actualOptions.sessionReplay.enableFastViewRendering)
+ }
+
+ func testEnableFastViewRenderingFalse() {
+ let optionsDict = ([
+ "dsn": "https://abc@def.ingest.sentry.io/1234567",
+ "replaysOnErrorSampleRate": 0.75,
+ "mobileReplayOptions": [ "enableFastViewRendering": false ]
+ ] as NSDictionary).mutableCopy() as! NSMutableDictionary
+
+ RNSentryReplay.updateOptions(optionsDict)
+
+ let actualOptions = try! Options(dict: optionsDict as! [String: Any])
+
+ XCTAssertFalse(actualOptions.sessionReplay.enableFastViewRendering)
+ }
+
}
diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTimeToDisplayTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTimeToDisplayTests.swift
new file mode 100644
index 0000000000..cdeb8258a4
--- /dev/null
+++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTimeToDisplayTests.swift
@@ -0,0 +1,60 @@
+import Sentry
+import XCTest
+
+final class RNSentryTimeToDisplayTests: XCTestCase {
+ private let TEST_ID = "test-id"
+ private let TEST_VAL = NSNumber(value: 123.4)
+
+ func testPutsAndPopsRecords() {
+ RNSentryTimeToDisplay.put(for: TEST_ID, value: TEST_VAL)
+
+ let firstPop = RNSentryTimeToDisplay.pop(for: TEST_ID)
+ let secondPop = RNSentryTimeToDisplay.pop(for: TEST_ID)
+
+ XCTAssert(firstPop == TEST_VAL)
+ XCTAssertNil(secondPop)
+ }
+
+ func testRemovesOldestEntryWhenFull() {
+ let maxSize = TIME_TO_DISPLAY_ENTRIES_MAX_SIZE + 1
+ for i in 1...maxSize {
+ RNSentryTimeToDisplay.put(for: "\(TEST_ID)-\(i)", value: NSNumber(value: i))
+ }
+
+ let oldestEntry = RNSentryTimeToDisplay.pop(for: "\(TEST_ID)-1")
+ let secondOldestEntry = RNSentryTimeToDisplay.pop(for: "\(TEST_ID)-2")
+ let newestEntry = RNSentryTimeToDisplay.pop(for: "\(TEST_ID)-\(maxSize)")
+
+ XCTAssertNil(oldestEntry)
+ XCTAssertNotNil(secondOldestEntry)
+ XCTAssertNotNil(newestEntry)
+ }
+
+ func testHandlesEarlyPoppedValues() {
+ let maxSize = TIME_TO_DISPLAY_ENTRIES_MAX_SIZE + 1
+ for i in 1...maxSize {
+ let key = "\(TEST_ID)-\(i)"
+ RNSentryTimeToDisplay.put(for: key, value: NSNumber(value: i))
+ RNSentryTimeToDisplay.pop(for: key)
+ }
+
+ // Age counter reached the max size, but storage is empty
+ // The internal structures should handle the situation
+
+ let nextKey1 = "\(TEST_ID)-next-1"
+ let nextKey2 = "\(TEST_ID)-next-2"
+ let nextVal1 = NSNumber(value: 123.4)
+ let nextVal2 = NSNumber(value: 567.8)
+ RNSentryTimeToDisplay.put(for: nextKey1, value: nextVal1)
+ RNSentryTimeToDisplay.put(for: nextKey2, value: nextVal2)
+
+ let nextActualVal1 = RNSentryTimeToDisplay.pop(for: nextKey1)
+ let nextActualVal2 = RNSentryTimeToDisplay.pop(for: nextKey2)
+
+ XCTAssertEqual(nextVal1, nextActualVal1)
+ XCTAssertEqual(nextVal2, nextActualVal2)
+
+ XCTAssertNil(RNSentryTimeToDisplay.pop(for: nextKey1))
+ XCTAssertNil(RNSentryTimeToDisplay.pop(for: nextKey2))
+ }
+}
diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle
index c96506fb3c..292f7f6417 100644
--- a/packages/core/android/build.gradle
+++ b/packages/core/android/build.gradle
@@ -54,5 +54,5 @@ android {
dependencies {
implementation 'com.facebook.react:react-native:+'
- api 'io.sentry:sentry-android:7.22.0'
+ api 'io.sentry:sentry-android:7.22.5'
}
diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java
index 52587e7cb8..39e206d678 100644
--- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java
+++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java
@@ -12,6 +12,7 @@
import android.content.res.AssetManager;
import android.net.Uri;
import android.util.SparseIntArray;
+import androidx.annotation.VisibleForTesting;
import androidx.core.app.FrameMetricsAggregator;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
@@ -19,6 +20,7 @@
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
+import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.ReadableMapKeySetIterator;
import com.facebook.react.bridge.UiThreadUtil;
@@ -26,7 +28,6 @@
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.bridge.WritableNativeArray;
import com.facebook.react.bridge.WritableNativeMap;
-import com.facebook.react.modules.core.DeviceEventManagerModule;
import io.sentry.Breadcrumb;
import io.sentry.HubAdapter;
import io.sentry.ILogger;
@@ -74,6 +75,7 @@
import java.util.concurrent.CountDownLatch;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.TestOnly;
public class RNSentryModuleImpl {
@@ -89,7 +91,7 @@ public class RNSentryModuleImpl {
private FrameMetricsAggregator frameMetricsAggregator = null;
private boolean androidXAvailable;
- private static boolean hasFetchedAppStart;
+ @VisibleForTesting static long lastStartTimestampMs = -1;
// 700ms to constitute frozen frames.
private static final int FROZEN_FRAME_THRESHOLD = 700;
@@ -137,11 +139,7 @@ private ReactApplicationContext getReactApplicationContext() {
private @NotNull Runnable createEmitNewFrameEvent() {
return () -> {
final SentryDate endDate = dateProvider.now();
- WritableMap event = Arguments.createMap();
- event.putDouble("newFrameTimestampInSeconds", endDate.nanoTimestamp() / 1e9);
- getReactApplicationContext()
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
- .emit("rn_sentry_new_frame", event);
+ RNSentryTimeToDisplay.putTimeToInitialDisplayForActiveSpan(endDate.nanoTimestamp() / 1e9);
};
}
@@ -165,11 +163,22 @@ public void initNativeReactNavigationNewFrameTracking(Promise promise) {
public void initNativeSdk(final ReadableMap rnOptions, Promise promise) {
RNSentryStart.startWithOptions(
- this.getReactApplicationContext(), rnOptions, getCurrentActivity(), logger);
+ getApplicationContext(), rnOptions, getCurrentActivity(), logger);
promise.resolve(true);
}
+ @TestOnly
+ protected Context getApplicationContext() {
+ final Context context = this.getReactApplicationContext().getApplicationContext();
+ if (context == null) {
+ logger.log(
+ SentryLevel.ERROR, "ApplicationContext is null, using ReactApplicationContext fallback.");
+ return this.getReactApplicationContext();
+ }
+ return context;
+ }
+
public void crash() {
throw new RuntimeException("TEST - Sentry Client Crash (only works in release mode)");
}
@@ -212,31 +221,44 @@ public void fetchNativeRelease(Promise promise) {
public void fetchNativeAppStart(Promise promise) {
fetchNativeAppStart(
- promise,
- InternalSentrySdk.getAppStartMeasurement(),
- logger,
- AppStartMetrics.getInstance().isAppLaunchedInForeground());
+ promise, AppStartMetrics.getInstance(), InternalSentrySdk.getAppStartMeasurement(), logger);
}
protected void fetchNativeAppStart(
Promise promise,
- final Map appStartMeasurement,
- ILogger logger,
- boolean isAppLaunchedInForeground) {
- if (!isAppLaunchedInForeground) {
+ final AppStartMetrics metrics,
+ final Map metricsDataBag,
+ ILogger logger) {
+ if (!metrics.isAppLaunchedInForeground()) {
logger.log(SentryLevel.WARNING, "Invalid app start data: app not launched in foreground.");
promise.resolve(null);
return;
}
WritableMap mutableMeasurement =
- (WritableMap) RNSentryMapConverter.convertToWritable(appStartMeasurement);
- mutableMeasurement.putBoolean("has_fetched", hasFetchedAppStart);
+ (WritableMap) RNSentryMapConverter.convertToWritable(metricsDataBag);
+
+ long currentStartTimestampMs = metrics.getAppStartTimeSpan().getStartTimestampMs();
+ boolean hasFetched =
+ lastStartTimestampMs > 0 && lastStartTimestampMs == currentStartTimestampMs;
+ mutableMeasurement.putBoolean("has_fetched", hasFetched);
+
+ if (lastStartTimestampMs < 0) {
+ logger.log(SentryLevel.DEBUG, "App Start data reported to the RN layer for the first time.");
+ } else if (hasFetched) {
+ logger.log(SentryLevel.DEBUG, "App Start data already fetched from native before.");
+ } else {
+ logger.log(SentryLevel.DEBUG, "App Start data updated, reporting to the RN layer again.");
+ }
+
+ // When activity is destroyed but the application process is kept alive
+ // the next activity creation is considered warm start.
+ // The app start metrics will be updated by the the Android SDK.
+ // To let the RN JS layer know these are new start data we compare the start timestamps.
+ lastStartTimestampMs = currentStartTimestampMs;
- // This is always set to true, as we would only allow an app start fetch to only
- // happen once in the case of a JS bundle reload, we do not want it to be
- // instrumented again.
- hasFetchedAppStart = true;
+ // Clears start metrics, making them ready for recording warm app start
+ metrics.onAppStartSpansSent();
promise.resolve(mutableMeasurement);
}
@@ -483,6 +505,19 @@ public void clearBreadcrumbs() {
});
}
+ public void popTimeToDisplayFor(String screenId, Promise promise) {
+ if (screenId != null) {
+ promise.resolve(RNSentryTimeToDisplay.popTimeToDisplayFor(screenId));
+ } else {
+ promise.resolve(null);
+ }
+ }
+
+ public boolean setActiveSpanId(@Nullable String spanId) {
+ RNSentryTimeToDisplay.setActiveSpanId(spanId);
+ return true; // The return ensure RN executes the code synchronously
+ }
+
public void setExtra(String key, String extra) {
if (key == null || extra == null) {
logger.log(
@@ -785,6 +820,15 @@ public void getDataFromUri(String uri, Promise promise) {
}
}
+ public void encodeToBase64(ReadableArray array, Promise promise) {
+ byte[] bytes = new byte[array.size()];
+ for (int i = 0; i < array.size(); i++) {
+ bytes[i] = (byte) array.getInt(i);
+ }
+ String base64String = android.util.Base64.encodeToString(bytes, android.util.Base64.DEFAULT);
+ promise.resolve(base64String);
+ }
+
public void crashedLastRun(Promise promise) {
promise.resolve(Sentry.isCrashedLastRun());
}
diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java
index a412ab51b5..ce7497db7c 100644
--- a/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java
+++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java
@@ -3,30 +3,29 @@
import android.app.Activity;
import android.content.Context;
import android.view.View;
-import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactApplicationContext;
-import com.facebook.react.bridge.WritableMap;
-import com.facebook.react.common.MapBuilder;
import com.facebook.react.uimanager.SimpleViewManager;
import com.facebook.react.uimanager.ThemedReactContext;
import com.facebook.react.uimanager.annotations.ReactProp;
-import com.facebook.react.uimanager.events.RCTEventEmitter;
import io.sentry.ILogger;
-import io.sentry.SentryDate;
import io.sentry.SentryDateProvider;
import io.sentry.SentryLevel;
import io.sentry.android.core.AndroidLogger;
import io.sentry.android.core.BuildInfoProvider;
import io.sentry.android.core.SentryAndroidDateProvider;
import io.sentry.android.core.internal.util.FirstDrawDoneListener;
-import java.util.Map;
+import io.sentry.react.utils.RNSentryActivityUtils;
+import java.util.Objects;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.TestOnly;
public class RNSentryOnDrawReporterManager
extends SimpleViewManager {
public static final String REACT_CLASS = "RNSentryOnDrawReporter";
+ public static final String TTID_PREFIX = "ttid-";
+ public static final String TTFD_PREFIX = "ttfd-";
private final @NotNull ReactApplicationContext mCallerContext;
public RNSentryOnDrawReporterManager(ReactApplicationContext reactContext) {
@@ -57,12 +56,9 @@ public void setFullDisplay(RNSentryOnDrawReporterView view, boolean fullDisplay)
view.setFullDisplay(fullDisplay);
}
- public Map getExportedCustomBubblingEventTypeConstants() {
- return MapBuilder.builder()
- .put(
- "onDrawNextFrameView",
- MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onDrawNextFrame")))
- .build();
+ @ReactProp(name = "parentSpanId")
+ public void setParentSpanId(RNSentryOnDrawReporterView view, String parentSpanId) {
+ view.setParentSpanId(parentSpanId);
}
public static class RNSentryOnDrawReporterView extends View {
@@ -71,16 +67,17 @@ public static class RNSentryOnDrawReporterView extends View {
private final @Nullable ReactApplicationContext reactContext;
private final @NotNull SentryDateProvider dateProvider = new SentryAndroidDateProvider();
- private final @Nullable Runnable emitInitialDisplayEvent;
- private final @Nullable Runnable emitFullDisplayEvent;
private final @Nullable BuildInfoProvider buildInfo;
+ private boolean isInitialDisplay = false;
+ private boolean isFullDisplay = false;
+ private boolean spanIdUsed = false;
+ private @Nullable String parentSpanId = null;
+
public RNSentryOnDrawReporterView(@NotNull Context context) {
super(context);
reactContext = null;
buildInfo = null;
- emitInitialDisplayEvent = null;
- emitFullDisplayEvent = null;
}
public RNSentryOnDrawReporterView(
@@ -88,35 +85,60 @@ public RNSentryOnDrawReporterView(
super(context);
reactContext = context;
buildInfo = buildInfoProvider;
- emitInitialDisplayEvent = () -> emitDisplayEvent("initialDisplay");
- emitFullDisplayEvent = () -> emitDisplayEvent("fullDisplay");
}
- public void setFullDisplay(boolean fullDisplay) {
- if (!fullDisplay) {
- return;
- }
+ @TestOnly
+ public RNSentryOnDrawReporterView(
+ @NotNull Context context,
+ @NotNull ReactApplicationContext reactContext,
+ @NotNull BuildInfoProvider buildInfoProvider) {
+ super(context);
+ this.reactContext = reactContext;
+ buildInfo = buildInfoProvider;
+ }
- logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register full display event emitter.");
- registerForNextDraw(emitFullDisplayEvent);
+ public void setFullDisplay(boolean newIsFullDisplay) {
+ if (newIsFullDisplay != isFullDisplay) {
+ isFullDisplay = newIsFullDisplay;
+ processPropsChanged();
+ }
}
- public void setInitialDisplay(boolean initialDisplay) {
- if (!initialDisplay) {
- return;
+ public void setInitialDisplay(boolean newIsInitialDisplay) {
+ if (newIsInitialDisplay != isInitialDisplay) {
+ isInitialDisplay = newIsInitialDisplay;
+ processPropsChanged();
}
+ }
- logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register initial display event emitter.");
- registerForNextDraw(emitInitialDisplayEvent);
+ public void setParentSpanId(@Nullable String newParentSpanId) {
+ if (!Objects.equals(newParentSpanId, parentSpanId)) {
+ parentSpanId = newParentSpanId;
+ spanIdUsed = false;
+ processPropsChanged();
+ }
}
- private void registerForNextDraw(@Nullable Runnable emitter) {
- if (emitter == null) {
+ private void processPropsChanged() {
+ if (parentSpanId == null) {
+ return;
+ }
+ if (spanIdUsed) {
logger.log(
- SentryLevel.ERROR,
- "[TimeToDisplay] Won't emit next frame drawn event, emitter is null.");
+ SentryLevel.DEBUG,
+ "[TimeToDisplay] Already recorded time to display for spanId: " + parentSpanId);
+ return;
+ }
+
+ if (isInitialDisplay) {
+ logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register initial display event emitter.");
+ } else if (isFullDisplay) {
+ logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register full display event emitter.");
+ } else {
+ logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Not ready, missing displayType prop.");
return;
}
+
if (buildInfo == null) {
logger.log(
SentryLevel.ERROR,
@@ -130,34 +152,45 @@ private void registerForNextDraw(@Nullable Runnable emitter) {
return;
}
- @Nullable Activity activity = reactContext.getCurrentActivity();
+ final @Nullable Activity activity =
+ RNSentryActivityUtils.getCurrentActivity(reactContext, logger);
if (activity == null) {
logger.log(
SentryLevel.ERROR,
- "[TimeToDisplay] Won't emit next frame drawn event, reactContext is null.");
+ "[TimeToDisplay] Won't emit next frame drawn event, activity is null.");
return;
}
- FirstDrawDoneListener.registerForNextDraw(activity, emitter, buildInfo);
+ spanIdUsed = true;
+ registerForNextDraw(
+ activity,
+ () -> {
+ final Double now = dateProvider.now().nanoTimestamp() / 1e9;
+ if (parentSpanId == null) {
+ logger.log(
+ SentryLevel.ERROR,
+ "[TimeToDisplay] parentSpanId removed before frame was rendered.");
+ return;
+ }
+
+ if (isInitialDisplay) {
+ RNSentryTimeToDisplay.putTimeToDisplayFor(TTID_PREFIX + parentSpanId, now);
+ } else if (isFullDisplay) {
+ RNSentryTimeToDisplay.putTimeToDisplayFor(TTFD_PREFIX + parentSpanId, now);
+ } else {
+ logger.log(
+ SentryLevel.DEBUG,
+ "[TimeToDisplay] display type removed before frame was rendered.");
+ }
+ },
+ buildInfo);
}
- private void emitDisplayEvent(String type) {
- final SentryDate endDate = dateProvider.now();
-
- WritableMap event = Arguments.createMap();
- event.putString("type", type);
- event.putDouble("newFrameTimestampInSeconds", endDate.nanoTimestamp() / 1e9);
-
- if (reactContext == null) {
- logger.log(
- SentryLevel.ERROR,
- "[TimeToDisplay] Recorded next frame draw but can't emit the event, reactContext is"
- + " null.");
- return;
- }
- reactContext
- .getJSModule(RCTEventEmitter.class)
- .receiveEvent(getId(), "onDrawNextFrameView", event);
+ protected void registerForNextDraw(
+ final @NotNull Activity activity,
+ final @NotNull Runnable callback,
+ final @NotNull BuildInfoProvider buildInfo) {
+ FirstDrawDoneListener.registerForNextDraw(activity, callback, buildInfo);
}
}
}
diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java
index b6fab45492..1ef7c275bf 100644
--- a/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java
+++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java
@@ -6,11 +6,48 @@
import com.facebook.react.bridge.Promise;
import io.sentry.SentryDate;
import io.sentry.SentryDateProvider;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.jetbrains.annotations.Nullable;
public final class RNSentryTimeToDisplay {
private RNSentryTimeToDisplay() {}
+ public static final int ENTRIES_MAX_SIZE = 50;
+ private static final Map screenIdToRenderDuration =
+ new LinkedHashMap(ENTRIES_MAX_SIZE + 1, 0.75f, true) {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry eldest) {
+ return size() > ENTRIES_MAX_SIZE;
+ }
+ };
+
+ /**
+ * The active span id that is used to attribute the time to display to the active span in case of
+ * a screen navigation where native time to display is not available to assign the span id
+ * received from JS.
+ */
+ private static @Nullable String activeSpanId = null;
+
+ public static void setActiveSpanId(@Nullable String spanId) {
+ activeSpanId = spanId;
+ }
+
+ public static Double popTimeToDisplayFor(String screenId) {
+ return screenIdToRenderDuration.remove(screenId);
+ }
+
+ public static void putTimeToInitialDisplayForActiveSpan(Double value) {
+ if (activeSpanId != null) {
+ putTimeToDisplayFor("ttid-navigation-" + activeSpanId, value);
+ }
+ }
+
+ public static void putTimeToDisplayFor(String screenId, Double value) {
+ screenIdToRenderDuration.put(screenId, value);
+ }
+
public static void getTimeToDisplay(Promise promise, SentryDateProvider dateProvider) {
Looper mainLooper = Looper.getMainLooper();
diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java
index 749c271605..a2527f94a8 100644
--- a/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java
+++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java
@@ -2,7 +2,7 @@
class RNSentryVersion {
static final String REACT_NATIVE_SDK_PACKAGE_NAME = "npm:@sentry/react-native";
- static final String REACT_NATIVE_SDK_PACKAGE_VERSION = "6.8.0";
+ static final String REACT_NATIVE_SDK_PACKAGE_VERSION = "6.15.1";
static final String NATIVE_SDK_NAME = "sentry.native.android.react-native";
static final String ANDROID_SDK_NAME = "sentry.java.android.react-native";
static final String REACT_NATIVE_SDK_NAME = "sentry.javascript.react-native";
diff --git a/packages/core/android/src/main/java/io/sentry/react/utils/RNSentryActivityUtils.java b/packages/core/android/src/main/java/io/sentry/react/utils/RNSentryActivityUtils.java
new file mode 100644
index 0000000000..c63ab29553
--- /dev/null
+++ b/packages/core/android/src/main/java/io/sentry/react/utils/RNSentryActivityUtils.java
@@ -0,0 +1,31 @@
+package io.sentry.react.utils;
+
+import android.app.Activity;
+import com.facebook.react.bridge.ReactApplicationContext;
+import io.sentry.ILogger;
+import io.sentry.SentryLevel;
+import io.sentry.android.core.CurrentActivityHolder;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/** Utility class for React Native Activity related functionality. */
+public final class RNSentryActivityUtils {
+
+ private RNSentryActivityUtils() {
+ // Prevent instantiation
+ }
+
+ public static @Nullable Activity getCurrentActivity(
+ final @NotNull ReactApplicationContext reactContext, final @NotNull ILogger logger) {
+ final Activity activity = reactContext.getCurrentActivity();
+ if (activity != null) {
+ return activity;
+ }
+
+ logger.log(
+ SentryLevel.DEBUG,
+ "[RNSentryActivityUtils] Given ReactApplicationContext has no activity attached, using"
+ + " CurrentActivityHolder as a fallback.");
+ return CurrentActivityHolder.getInstance().getActivity();
+ }
+}
diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java
index 92ef2c0614..5b14f05c92 100644
--- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java
+++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java
@@ -182,4 +182,19 @@ public void getNewScreenTimeToDisplay(Promise promise) {
public void getDataFromUri(String uri, Promise promise) {
this.impl.getDataFromUri(uri, promise);
}
+
+ @Override
+ public void encodeToBase64(ReadableArray array, Promise promise) {
+ this.impl.encodeToBase64(array, promise);
+ }
+
+ @Override
+ public void popTimeToDisplayFor(String key, Promise promise) {
+ this.impl.popTimeToDisplayFor(key, promise);
+ }
+
+ @Override
+ public boolean setActiveSpanId(String spanId) {
+ return this.impl.setActiveSpanId(spanId);
+ }
}
diff --git a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java
index 7896d45fde..103afeb890 100644
--- a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java
+++ b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java
@@ -152,11 +152,6 @@ public String fetchNativePackageName() {
return this.impl.fetchNativePackageName();
}
- @ReactMethod
- public void getDataFromUri(String uri, Promise promise) {
- this.impl.getDataFromUri(uri, promise);
- }
-
@ReactMethod(isBlockingSynchronousMethod = true)
public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) {
// Not used on Android
@@ -178,8 +173,28 @@ public void crashedLastRun(Promise promise) {
this.impl.crashedLastRun(promise);
}
- @ReactMethod()
+ @ReactMethod
public void getNewScreenTimeToDisplay(Promise promise) {
this.impl.getNewScreenTimeToDisplay(promise);
}
+
+ @ReactMethod
+ public void getDataFromUri(String uri, Promise promise) {
+ this.impl.getDataFromUri(uri, promise);
+ }
+
+ @ReactMethod
+ public void encodeToBase64(ReadableArray array, Promise promise) {
+ this.impl.encodeToBase64(array, promise);
+ }
+
+ @ReactMethod
+ public void popTimeToDisplayFor(String key, Promise promise) {
+ this.impl.popTimeToDisplayFor(key, promise);
+ }
+
+ @ReactMethod
+ public boolean setActiveSpanId(String spanId) {
+ return this.impl.setActiveSpanId(spanId);
+ }
}
diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm
index fa6654a563..7b479e1d53 100644
--- a/packages/core/ios/RNSentry.mm
+++ b/packages/core/ios/RNSentry.mm
@@ -121,13 +121,8 @@ - (instancetype)init
- (void)initFramesTracking
{
#if SENTRY_HAS_UIKIT
-
RNSentryEmitNewFrameEvent emitNewFrameEvent = ^(NSNumber *newFrameTimestampInSeconds) {
- if (self->hasListeners) {
- [self
- sendEventWithName:RNSentryNewFrameEvent
- body:@{ @"newFrameTimestampInSeconds" : newFrameTimestampInSeconds }];
- }
+ [RNSentryTimeToDisplay putTimeToInitialDisplayForActiveSpan:newFrameTimestampInSeconds];
};
[[RNSentryDependencyContainer sharedInstance]
initializeFramesTrackerListenerWith:emitNewFrameEvent];
@@ -794,4 +789,42 @@ + (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys
[_timeToDisplay getTimeToDisplay:resolve];
}
+RCT_EXPORT_METHOD(popTimeToDisplayFor
+ : (NSString *)key resolver
+ : (RCTPromiseResolveBlock)resolve rejecter
+ : (RCTPromiseRejectBlock)reject)
+{
+ resolve([RNSentryTimeToDisplay popTimeToDisplayFor:key]);
+}
+
+RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSNumber *, setActiveSpanId : (NSString *)spanId)
+{
+ [RNSentryTimeToDisplay setActiveSpanId:spanId];
+ return @YES; // The return ensures that the method is synchronous
+}
+
+RCT_EXPORT_METHOD(encodeToBase64
+ : (NSArray *)array resolver
+ : (RCTPromiseResolveBlock)resolve rejecter
+ : (RCTPromiseRejectBlock)reject)
+{
+ NSUInteger count = array.count;
+ uint8_t *bytes = (uint8_t *)malloc(count);
+
+ if (!bytes) {
+ reject(@"encodeToBase64", @"Memory allocation failed", nil);
+ return;
+ }
+
+ for (NSUInteger i = 0; i < count; i++) {
+ bytes[i] = (uint8_t)[array[i] unsignedCharValue];
+ }
+
+ NSData *data = [NSData dataWithBytes:bytes length:count];
+ free(bytes);
+
+ NSString *base64String = [data base64EncodedStringWithOptions:0];
+ resolve(base64String);
+}
+
@end
diff --git a/packages/core/ios/RNSentryBreadcrumb.m b/packages/core/ios/RNSentryBreadcrumb.m
index db9ffce2d6..cc77dce70a 100644
--- a/packages/core/ios/RNSentryBreadcrumb.m
+++ b/packages/core/ios/RNSentryBreadcrumb.m
@@ -39,7 +39,8 @@ + (SentryBreadcrumb *)from:(NSDictionary *)dict
+ (NSString *_Nullable)getCurrentScreenFrom:(NSDictionary *_Nonnull)dict
{
NSString *_Nullable maybeCategory = [dict valueForKey:@"category"];
- if (![maybeCategory isEqualToString:@"navigation"]) {
+ if ([maybeCategory isKindOfClass:[NSString class]]
+ && ![maybeCategory isEqualToString:@"navigation"]) {
return nil;
}
diff --git a/packages/core/ios/RNSentryFramesTrackerListener.h b/packages/core/ios/RNSentryFramesTrackerListener.h
index e0de09dfd9..627b3059f4 100644
--- a/packages/core/ios/RNSentryFramesTrackerListener.h
+++ b/packages/core/ios/RNSentryFramesTrackerListener.h
@@ -8,13 +8,17 @@
typedef void (^RNSentryEmitNewFrameEvent)(NSNumber *newFrameTimestampInSeconds);
-@interface RNSentryFramesTrackerListener : NSObject
+@protocol RNSentryFramesTrackerListenerProtocol
+
+- (void)startListening;
+
+@end
+
+@interface RNSentryFramesTrackerListener : NSObject
- (instancetype)initWithSentryFramesTracker:(SentryFramesTracker *)framesTracker
andEventEmitter:(RNSentryEmitNewFrameEvent)emitNewFrameEvent;
-- (void)startListening;
-
@property (strong, nonatomic) SentryFramesTracker *framesTracker;
@property (strong, nonatomic) RNSentryEmitNewFrameEvent emitNewFrameEvent;
diff --git a/packages/core/ios/RNSentryOnDrawReporter.h b/packages/core/ios/RNSentryOnDrawReporter.h
index 1cf9fb6245..5c4083015d 100644
--- a/packages/core/ios/RNSentryOnDrawReporter.h
+++ b/packages/core/ios/RNSentryOnDrawReporter.h
@@ -12,11 +12,15 @@
@interface RNSentryOnDrawReporterView : UIView
-@property (nonatomic, strong) RNSentryFramesTrackerListener *framesListener;
-@property (nonatomic, copy) RCTBubblingEventBlock onDrawNextFrame;
+@property (nonatomic, strong) id framesListener;
@property (nonatomic) bool fullDisplay;
@property (nonatomic) bool initialDisplay;
+@property (nonatomic) bool spanIdUsed;
+@property (nonatomic, copy) NSString *parentSpanId;
@property (nonatomic, weak) RNSentryOnDrawReporter *delegate;
+@property (nonatomic) bool previousFullDisplay;
+@property (nonatomic) bool previousInitialDisplay;
+@property (nonatomic, copy) NSString *previousParentSpanId;
@end
diff --git a/packages/core/ios/RNSentryOnDrawReporter.m b/packages/core/ios/RNSentryOnDrawReporter.m
index d8266c73a5..dc11bc9c51 100644
--- a/packages/core/ios/RNSentryOnDrawReporter.m
+++ b/packages/core/ios/RNSentryOnDrawReporter.m
@@ -1,4 +1,5 @@
#import "RNSentryOnDrawReporter.h"
+#import "RNSentryTimeToDisplay.h"
#if SENTRY_HAS_UIKIT
@@ -7,9 +8,9 @@
@implementation RNSentryOnDrawReporter
RCT_EXPORT_MODULE(RNSentryOnDrawReporter)
-RCT_EXPORT_VIEW_PROPERTY(onDrawNextFrame, RCTBubblingEventBlock)
RCT_EXPORT_VIEW_PROPERTY(initialDisplay, BOOL)
RCT_EXPORT_VIEW_PROPERTY(fullDisplay, BOOL)
+RCT_EXPORT_VIEW_PROPERTY(parentSpanId, NSString)
- (UIView *)view
{
@@ -19,29 +20,16 @@ - (UIView *)view
@end
-@implementation RNSentryOnDrawReporterView
+@implementation RNSentryOnDrawReporterView {
+ BOOL isListening;
+}
- (instancetype)init
{
self = [super init];
if (self) {
- RNSentryEmitNewFrameEvent emitNewFrameEvent = ^(NSNumber *newFrameTimestampInSeconds) {
- if (self->_fullDisplay) {
- self.onDrawNextFrame(@{
- @"newFrameTimestampInSeconds" : newFrameTimestampInSeconds,
- @"type" : @"fullDisplay"
- });
- return;
- }
-
- if (self->_initialDisplay) {
- self.onDrawNextFrame(@{
- @"newFrameTimestampInSeconds" : newFrameTimestampInSeconds,
- @"type" : @"initialDisplay"
- });
- return;
- }
- };
+ _spanIdUsed = NO;
+ RNSentryEmitNewFrameEvent emitNewFrameEvent = [self createEmitNewFrameEvent];
_framesListener = [[RNSentryFramesTrackerListener alloc]
initWithSentryFramesTracker:[[SentryDependencyContainer sharedInstance] framesTracker]
andEventEmitter:emitNewFrameEvent];
@@ -49,10 +37,53 @@ - (instancetype)init
return self;
}
+- (RNSentryEmitNewFrameEvent)createEmitNewFrameEvent
+{
+ return ^(NSNumber *newFrameTimestampInSeconds) {
+ self->isListening = NO;
+
+ if (![_parentSpanId isKindOfClass:[NSString class]]) {
+ return;
+ }
+
+ if (self->_fullDisplay) {
+ [RNSentryTimeToDisplay
+ putTimeToDisplayFor:[@"ttfd-" stringByAppendingString:self->_parentSpanId]
+ value:newFrameTimestampInSeconds];
+ return;
+ }
+
+ if (self->_initialDisplay) {
+ [RNSentryTimeToDisplay
+ putTimeToDisplayFor:[@"ttid-" stringByAppendingString:self->_parentSpanId]
+ value:newFrameTimestampInSeconds];
+ return;
+ }
+ };
+}
+
- (void)didSetProps:(NSArray *)changedProps
{
+ if (![_parentSpanId isKindOfClass:[NSString class]]) {
+ _previousParentSpanId = nil;
+ return;
+ }
+
+ if ([_parentSpanId isEqualToString:_previousParentSpanId] && _spanIdUsed) {
+ _previousInitialDisplay = _initialDisplay;
+ _previousFullDisplay = _fullDisplay;
+ return;
+ }
+
+ _previousParentSpanId = _parentSpanId;
+ _spanIdUsed = NO;
+
if (_fullDisplay || _initialDisplay) {
- [_framesListener startListening];
+ if (!isListening && !_spanIdUsed) {
+ _spanIdUsed = YES;
+ isListening = YES;
+ [_framesListener startListening];
+ }
}
}
diff --git a/packages/core/ios/RNSentryReplay.mm b/packages/core/ios/RNSentryReplay.mm
index b43fae4a49..994ec36189 100644
--- a/packages/core/ios/RNSentryReplay.mm
+++ b/packages/core/ios/RNSentryReplay.mm
@@ -27,6 +27,8 @@ + (void)updateOptions:(NSMutableDictionary *)options
@"errorSampleRate" : options[@"replaysOnErrorSampleRate"] ?: [NSNull null],
@"maskAllImages" : replayOptions[@"maskAllImages"] ?: [NSNull null],
@"maskAllText" : replayOptions[@"maskAllText"] ?: [NSNull null],
+ @"enableViewRendererV2" : replayOptions[@"enableViewRendererV2"] ?: [NSNull null],
+ @"enableFastViewRendering" : replayOptions[@"enableFastViewRendering"] ?: [NSNull null],
@"maskedViewClasses" : [RNSentryReplay getReplayRNRedactClasses:replayOptions],
@"sdkInfo" :
@ { @"name" : REACT_NATIVE_SDK_NAME, @"version" : REACT_NATIVE_SDK_PACKAGE_VERSION }
diff --git a/packages/core/ios/RNSentryTimeToDisplay.h b/packages/core/ios/RNSentryTimeToDisplay.h
index fbb468cb23..4b749eddc8 100644
--- a/packages/core/ios/RNSentryTimeToDisplay.h
+++ b/packages/core/ios/RNSentryTimeToDisplay.h
@@ -1,7 +1,14 @@
#import
+static const int TIME_TO_DISPLAY_ENTRIES_MAX_SIZE = 50;
+
@interface RNSentryTimeToDisplay : NSObject
++ (NSNumber *)popTimeToDisplayFor:(NSString *)screenId;
++ (void)putTimeToDisplayFor:(NSString *)screenId value:(NSNumber *)value;
++ (void)setActiveSpanId:(NSString *)spanId;
++ (void)putTimeToInitialDisplayForActiveSpan:(NSNumber *)timestampSeconds;
+
- (void)getTimeToDisplay:(RCTResponseSenderBlock)callback;
@end
diff --git a/packages/core/ios/RNSentryTimeToDisplay.m b/packages/core/ios/RNSentryTimeToDisplay.m
index 9404cf5088..41044b347c 100644
--- a/packages/core/ios/RNSentryTimeToDisplay.m
+++ b/packages/core/ios/RNSentryTimeToDisplay.m
@@ -7,6 +7,74 @@ @implementation RNSentryTimeToDisplay {
RCTResponseSenderBlock resolveBlock;
}
+static NSMutableDictionary *screenIdToRenderDuration;
+static NSMutableArray *screenIdAge;
+static NSUInteger screenIdCurrentIndex;
+
+static NSString *activeSpanId;
+
++ (void)initialize
+{
+ if (self == [RNSentryTimeToDisplay class]) {
+ screenIdToRenderDuration =
+ [[NSMutableDictionary alloc] initWithCapacity:TIME_TO_DISPLAY_ENTRIES_MAX_SIZE];
+ screenIdAge = [[NSMutableArray alloc] initWithCapacity:TIME_TO_DISPLAY_ENTRIES_MAX_SIZE];
+ screenIdCurrentIndex = 0;
+
+ activeSpanId = nil;
+ }
+}
+
++ (void)setActiveSpanId:(NSString *)spanId
+{
+ activeSpanId = spanId;
+}
+
++ (NSNumber *)popTimeToDisplayFor:(NSString *)screenId
+{
+ NSNumber *value = screenIdToRenderDuration[screenId];
+ [screenIdToRenderDuration removeObjectForKey:screenId];
+ return value;
+}
+
++ (void)putTimeToInitialDisplayForActiveSpan:(NSNumber *)value
+{
+ if (activeSpanId != nil) {
+ NSString *prefixedSpanId = [@"ttid-navigation-" stringByAppendingString:activeSpanId];
+ [self putTimeToDisplayFor:prefixedSpanId value:value];
+ }
+}
+
++ (void)putTimeToDisplayFor:(NSString *)screenId value:(NSNumber *)value
+{
+ if (!screenId)
+ return;
+
+ // If key already exists, just update the value,
+ // this should never happen as TTD is recorded once per navigation
+ // We avoid updating the age to avoid the age array shift
+ if ([screenIdToRenderDuration objectForKey:screenId]) {
+ [screenIdToRenderDuration setObject:value forKey:screenId];
+ return;
+ }
+
+ // If we haven't reached capacity yet, just append
+ if (screenIdAge.count < TIME_TO_DISPLAY_ENTRIES_MAX_SIZE) {
+ [screenIdToRenderDuration setObject:value forKey:screenId];
+ [screenIdAge addObject:screenId];
+ } else {
+ // Remove oldest entry, in most case will already be removed by pop
+ NSString *oldestKey = screenIdAge[screenIdCurrentIndex];
+ [screenIdToRenderDuration removeObjectForKey:oldestKey];
+
+ [screenIdToRenderDuration setObject:value forKey:screenId];
+ screenIdAge[screenIdCurrentIndex] = screenId;
+
+ // Update circular index, point to the new oldest
+ screenIdCurrentIndex = (screenIdCurrentIndex + 1) % TIME_TO_DISPLAY_ENTRIES_MAX_SIZE;
+ }
+}
+
// Rename requestAnimationFrame to getTimeToDisplay
- (void)getTimeToDisplay:(RCTResponseSenderBlock)callback
{
@@ -26,8 +94,7 @@ - (void)getTimeToDisplay:(RCTResponseSenderBlock)callback
- (void)handleDisplayLink:(CADisplayLink *)link
{
// Get the current time
- NSTimeInterval currentTime =
- [[NSDate date] timeIntervalSince1970] * 1000.0; // Convert to milliseconds
+ NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970];
// Ensure the callback is valid and pass the current time back
if (resolveBlock) {
diff --git a/packages/core/ios/RNSentryVersion.m b/packages/core/ios/RNSentryVersion.m
index ac2aeaaa2f..d868d3b7a6 100644
--- a/packages/core/ios/RNSentryVersion.m
+++ b/packages/core/ios/RNSentryVersion.m
@@ -3,4 +3,4 @@
NSString *const NATIVE_SDK_NAME = @"sentry.cocoa.react-native";
NSString *const REACT_NATIVE_SDK_NAME = @"sentry.javascript.react-native";
NSString *const REACT_NATIVE_SDK_PACKAGE_NAME = @"npm:@sentry/react-native";
-NSString *const REACT_NATIVE_SDK_PACKAGE_VERSION = @"6.8.0";
+NSString *const REACT_NATIVE_SDK_PACKAGE_VERSION = @"6.15.1";
diff --git a/packages/core/package.json b/packages/core/package.json
index 13389eb819..489784211d 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -2,7 +2,7 @@
"name": "@sentry/react-native",
"homepage": "https://github.com/getsentry/sentry-react-native",
"repository": "https://github.com/getsentry/sentry-react-native",
- "version": "6.8.0",
+ "version": "6.15.1",
"description": "Official Sentry SDK for react-native",
"typings": "dist/js/index.d.ts",
"types": "dist/js/index.d.ts",
@@ -65,9 +65,9 @@
"react-native": ">=0.65.0"
},
"dependencies": {
- "@sentry/babel-plugin-component-annotate": "3.2.0",
+ "@sentry/babel-plugin-component-annotate": "3.5.0",
"@sentry/browser": "8.54.0",
- "@sentry/cli": "2.42.1",
+ "@sentry/cli": "2.46.0",
"@sentry/core": "8.54.0",
"@sentry/react": "8.54.0",
"@sentry/types": "8.54.0",
@@ -75,13 +75,13 @@
},
"devDependencies": {
"@babel/core": "^7.25.2",
- "@expo/metro-config": "0.19.5",
+ "@expo/metro-config": "~0.20.0",
"@mswjs/interceptors": "^0.25.15",
"@react-native/babel-preset": "0.77.1",
"@sentry-internal/eslint-config-sdk": "8.54.0",
"@sentry-internal/eslint-plugin-sdk": "8.54.0",
"@sentry-internal/typescript": "8.54.0",
- "@sentry/wizard": "3.42.1",
+ "@sentry/wizard": "5.1.0",
"@testing-library/react-native": "^12.7.2",
"@types/jest": "^29.5.13",
"@types/node": "^20.9.3",
@@ -98,7 +98,7 @@
"eslint": "^7.6.0",
"eslint-plugin-react": "^7.20.6",
"eslint-plugin-react-native": "^3.8.1",
- "expo": "^52.0.0",
+ "expo": "^53.0.0",
"expo-module-scripts": "3.1.0",
"jest": "^29.6.2",
"jest-environment-jsdom": "^29.6.2",
diff --git a/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts b/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts
index b4df682137..27a9a4d904 100644
--- a/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts
+++ b/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts
@@ -84,13 +84,13 @@ export function withSentryAndroidGradlePlugin(
const sentryPlugin = `apply plugin: "io.sentry.android.gradle"`;
const sentryConfig = `
sentry {
- autoUploadProguardMapping = ${autoUploadProguardMapping}
+ autoUploadProguardMapping = ${autoUploadProguardMapping ? 'shouldSentryAutoUpload()' : 'false'}
includeProguardMapping = ${includeProguardMapping}
dexguardEnabled = ${dexguardEnabled}
- uploadNativeSymbols = ${uploadNativeSymbols}
- autoUploadNativeSymbols = ${autoUploadNativeSymbols}
+ uploadNativeSymbols = ${uploadNativeSymbols ? 'shouldSentryAutoUpload()' : 'false'}
+ autoUploadNativeSymbols = ${autoUploadNativeSymbols ? 'shouldSentryAutoUpload()' : 'false'}
includeNativeSources = ${includeNativeSources}
- includeSourceContext = ${includeSourceContext}
+ includeSourceContext = ${includeSourceContext ? 'shouldSentryAutoUpload()' : 'false'}
tracingInstrumentation {
enabled = false
}
diff --git a/packages/core/scripts/sentry-xcode-debug-files.sh b/packages/core/scripts/sentry-xcode-debug-files.sh
index dc6e48aed7..29e8e3967c 100755
--- a/packages/core/scripts/sentry-xcode-debug-files.sh
+++ b/packages/core/scripts/sentry-xcode-debug-files.sh
@@ -5,7 +5,10 @@
# print commands before executing them
set -x
-[ -z "$WITH_ENVIRONMENT" ] && WITH_ENVIRONMENT="../node_modules/react-native/scripts/xcode/with-environment.sh"
+# REACT_NATIVE_PATH first used in RN 0.74.0 Template https://github.com/facebook/react-native/commit/289e78388a87408e215a25108cb02511a05f5c80
+LOCAL_REACT_NATIVE_PATH="${REACT_NATIVE_PATH:-"../node_modules/react-native"}"
+
+[ -z "$WITH_ENVIRONMENT" ] && WITH_ENVIRONMENT="${LOCAL_REACT_NATIVE_PATH}/scripts/xcode/with-environment.sh"
if [ -f "$WITH_ENVIRONMENT" ]; then
# load envs if loader file exists (since rn 0.68)
@@ -26,12 +29,9 @@ RN_PROJECT_ROOT="${PROJECT_DIR}/.."
[ -z "$SENTRY_CLI_EXECUTABLE" ] && SENTRY_CLI_PACKAGE_PATH=$("$LOCAL_NODE_BINARY" --print "require('path').dirname(require.resolve('@sentry/cli/package.json'))")
[ -z "$SENTRY_CLI_EXECUTABLE" ] && SENTRY_CLI_EXECUTABLE="${SENTRY_CLI_PACKAGE_PATH}/bin/sentry-cli"
-[ -z "$SENTRY_FORCE_FOREGROUND"] && SENTRY_FORCE_FOREGROUND=true
-
-[[ "$SENTRY_FORCE_FOREGROUND" == true ]] && SENTRY_FORCE_FOREGROUND_FLAG="--force-foreground"
[[ $SENTRY_INCLUDE_NATIVE_SOURCES == "true" ]] && INCLUDE_SOURCES_FLAG="--include-sources" || INCLUDE_SOURCES_FLAG=""
-EXTRA_ARGS="$SENTRY_FORCE_FOREGROUND_FLAG $SENTRY_CLI_EXTRA_ARGS $SENTRY_CLI_DEBUG_FILES_UPLOAD_EXTRA_ARGS $INCLUDE_SOURCES_FLAG"
+EXTRA_ARGS="$SENTRY_CLI_EXTRA_ARGS $SENTRY_CLI_DEBUG_FILES_UPLOAD_EXTRA_ARGS $INCLUDE_SOURCES_FLAG"
UPLOAD_DEBUG_FILES="\"$SENTRY_CLI_EXECUTABLE\" debug-files upload $EXTRA_ARGS \"$DWARF_DSYM_FOLDER_PATH\""
diff --git a/packages/core/scripts/sentry-xcode.sh b/packages/core/scripts/sentry-xcode.sh
index 336d393220..6d0764b90a 100755
--- a/packages/core/scripts/sentry-xcode.sh
+++ b/packages/core/scripts/sentry-xcode.sh
@@ -19,16 +19,15 @@ RN_PROJECT_ROOT="${PROJECT_DIR}/.."
[ -z "$SENTRY_CLI_EXECUTABLE" ] && SENTRY_CLI_PACKAGE_PATH=$("$LOCAL_NODE_BINARY" --print "require('path').dirname(require.resolve('@sentry/cli/package.json'))")
[ -z "$SENTRY_CLI_EXECUTABLE" ] && SENTRY_CLI_EXECUTABLE="${SENTRY_CLI_PACKAGE_PATH}/bin/sentry-cli"
-[ -z "$SENTRY_FORCE_FOREGROUND"] && SENTRY_FORCE_FOREGROUND=true
-
REACT_NATIVE_XCODE=$1
-[[ "$SENTRY_FORCE_FOREGROUND" == true ]] && SENTRY_FORCE_FOREGROUND_FLAG="--force-foreground"
[[ "$AUTO_RELEASE" == false ]] && [[ -z "$BUNDLE_COMMAND" || "$BUNDLE_COMMAND" != "ram-bundle" ]] && NO_AUTO_RELEASE="--no-auto-release"
-ARGS="$SENTRY_FORCE_FOREGROUND_FLAG $NO_AUTO_RELEASE $SENTRY_CLI_EXTRA_ARGS $SENTRY_CLI_RN_XCODE_EXTRA_ARGS"
+ARGS="$NO_AUTO_RELEASE $SENTRY_CLI_EXTRA_ARGS $SENTRY_CLI_RN_XCODE_EXTRA_ARGS"
REACT_NATIVE_XCODE_WITH_SENTRY="\"$SENTRY_CLI_EXECUTABLE\" react-native xcode $ARGS \"$REACT_NATIVE_XCODE\""
+exitCode=0
+
if [ "$SENTRY_DISABLE_AUTO_UPLOAD" != true ]; then
# 'warning:' triggers a warning in Xcode, 'error:' triggers an error
set +x +e # disable printing commands otherwise we might print `error:` by accident and allow continuing on error
@@ -38,6 +37,7 @@ if [ "$SENTRY_DISABLE_AUTO_UPLOAD" != true ]; then
else
echo "error: sentry-cli - To disable source maps auto upload, set SENTRY_DISABLE_AUTO_UPLOAD=true in your environment variables. Or to allow failing upload, set SENTRY_ALLOW_FAILURE=true"
echo "error: sentry-cli - $SENTRY_XCODE_COMMAND_OUTPUT"
+ exitCode=1
fi
set -x -e # re-enable
else
@@ -70,3 +70,5 @@ if [ "$SENTRY_COPY_OPTIONS_FILE" = true ]; then
echo "[Sentry] Copied $SENTRY_OPTIONS_FILE_PATH to $SENTRY_OPTIONS_FILE_DESTINATION_PATH"
fi
fi
+
+exit $exitCode
diff --git a/packages/core/sentry.gradle b/packages/core/sentry.gradle
index 990703527f..66f020d7bc 100644
--- a/packages/core/sentry.gradle
+++ b/packages/core/sentry.gradle
@@ -21,7 +21,7 @@ project.ext.shouldCopySentryOptionsFile = { -> // If not set, default to true
def config = project.hasProperty("sentryCli") ? project.sentryCli : [];
-def configFile = "sentry.options.json" // Sentry condiguration file
+def configFile = "sentry.options.json" // Sentry configuration file
def androidAssetsDir = new File("$rootDir/app/src/main/assets") // Path to Android assets folder
tasks.register("copySentryJsonConfiguration") {
@@ -57,7 +57,9 @@ tasks.register("cleanupTemporarySentryJsonConfiguration") {
}
}
-gradle.projectsEvaluated {
+// gradle.projectsEvaluated doesn't work with --configure-on-demand
+// the task are create too late and not executed
+project.afterEvaluate {
// Add a task that copies the sentry.options.json file before the build starts
tasks.named("preBuild").configure {
dependsOn("copySentryJsonConfiguration")
diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts
index 125dc3b082..5b00b62116 100644
--- a/packages/core/src/js/NativeRNSentry.ts
+++ b/packages/core/src/js/NativeRNSentry.ts
@@ -49,6 +49,9 @@ export interface Spec extends TurboModule {
getCurrentReplayId(): string | undefined | null;
crashedLastRun(): Promise;
getDataFromUri(uri: string): Promise;
+ popTimeToDisplayFor(key: string): Promise;
+ setActiveSpanId(spanId: string): boolean;
+ encodeToBase64(data: number[]): Promise;
}
export type NativeStackFrame = {
diff --git a/packages/core/src/js/client.ts b/packages/core/src/js/client.ts
index d838167093..6fcad6e513 100644
--- a/packages/core/src/js/client.ts
+++ b/packages/core/src/js/client.ts
@@ -140,6 +140,26 @@ export class ReactNativeClient extends BaseClient {
this._initNativeSdk();
}
+ /**
+ * Register a hook on this client.
+ *
+ * (Generic method signature to allow for custom React Native Client events.)
+ */
+ public on(hook: string, callback: unknown): () => void {
+ // @ts-expect-error on from the base class doesn't support generic types
+ return super.on(hook, callback);
+ }
+
+ /**
+ * Emit a hook that was previously registered via `on()`.
+ *
+ * (Generic method signature to allow for custom React Native Client events.)
+ */
+ public emit(hook: string, ...rest: unknown[]): void {
+ // @ts-expect-error emit from the base class doesn't support generic types
+ super.emit(hook, ...rest);
+ }
+
/**
* Starts native client with dsn and options
*/
@@ -165,6 +185,7 @@ export class ReactNativeClient extends BaseClient {
)
.then((didCallNativeInit: boolean) => {
this._options.onReady?.({ didCallNativeInit });
+ this.emit('afterInit');
})
.then(undefined, error => {
logger.error('The OnReady callback threw an error: ', error);
diff --git a/packages/core/src/js/feedback/FeedbackButton.tsx b/packages/core/src/js/feedback/FeedbackButton.tsx
new file mode 100644
index 0000000000..fbb546db8d
--- /dev/null
+++ b/packages/core/src/js/feedback/FeedbackButton.tsx
@@ -0,0 +1,66 @@
+import * as React from 'react';
+import type { NativeEventSubscription} from 'react-native';
+import { Appearance, Image, Text, TouchableOpacity } from 'react-native';
+
+import { defaultButtonConfiguration } from './defaults';
+import { defaultButtonStyles } from './FeedbackWidget.styles';
+import { getTheme } from './FeedbackWidget.theme';
+import type { FeedbackButtonProps, FeedbackButtonStyles, FeedbackButtonTextConfiguration } from './FeedbackWidget.types';
+import { showFeedbackWidget } from './FeedbackWidgetManager';
+import { feedbackIcon } from './icons';
+import { lazyLoadFeedbackIntegration } from './lazy';
+
+/**
+ * @beta
+ * Implements a feedback button that opens the FeedbackForm.
+ */
+export class FeedbackButton extends React.Component {
+ private _themeListener: NativeEventSubscription;
+
+ public constructor(props: FeedbackButtonProps) {
+ super(props);
+ lazyLoadFeedbackIntegration();
+ }
+
+ /**
+ * Adds a listener for theme changes.
+ */
+ public componentDidMount(): void {
+ this._themeListener = Appearance.addChangeListener(() => {
+ this.forceUpdate();
+ });
+ }
+
+ /**
+ * Removes the theme listener.
+ */
+ public componentWillUnmount(): void {
+ if (this._themeListener) {
+ this._themeListener.remove();
+ }
+ }
+
+ /**
+ * Renders the feedback button.
+ */
+ public render(): React.ReactNode {
+ const theme = getTheme();
+ const text: FeedbackButtonTextConfiguration = { ...defaultButtonConfiguration, ...this.props };
+ const styles: FeedbackButtonStyles = {
+ triggerButton: { ...defaultButtonStyles(theme).triggerButton, ...this.props.styles?.triggerButton },
+ triggerText: { ...defaultButtonStyles(theme).triggerText, ...this.props.styles?.triggerText },
+ triggerIcon: { ...defaultButtonStyles(theme).triggerIcon, ...this.props.styles?.triggerIcon },
+ };
+
+ return (
+
+
+ {text.triggerLabel}
+
+ );
+ }
+}
diff --git a/packages/core/src/js/feedback/FeedbackWidget.styles.ts b/packages/core/src/js/feedback/FeedbackWidget.styles.ts
index aebdb181e3..94df799d21 100644
--- a/packages/core/src/js/feedback/FeedbackWidget.styles.ts
+++ b/packages/core/src/js/feedback/FeedbackWidget.styles.ts
@@ -1,104 +1,153 @@
import type { ViewStyle } from 'react-native';
-import type { FeedbackWidgetStyles } from './FeedbackWidget.types';
+import type { FeedbackWidgetTheme } from './FeedbackWidget.theme';
+import type { FeedbackButtonStyles, FeedbackWidgetStyles } from './FeedbackWidget.types';
-const PURPLE = 'rgba(88, 74, 192, 1)';
-const FOREGROUND_COLOR = '#2b2233';
-const BACKGROUND_COLOR = '#ffffff';
-const BORDER_COLOR = 'rgba(41, 35, 47, 0.13)';
+const defaultStyles = (theme: FeedbackWidgetTheme): FeedbackWidgetStyles => {
+ return {
+ container: {
+ flex: 1,
+ padding: 20,
+ backgroundColor: theme.background,
+ },
+ title: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ marginBottom: 20,
+ textAlign: 'left',
+ flex: 1,
+ color: theme.foreground,
+ },
+ label: {
+ marginBottom: 4,
+ fontSize: 16,
+ color: theme.foreground,
+ },
+ input: {
+ height: 50,
+ borderColor: theme.border,
+ borderWidth: 1,
+ borderRadius: 5,
+ paddingHorizontal: 10,
+ marginBottom: 15,
+ fontSize: 16,
+ color: theme.foreground,
+ },
+ textArea: {
+ height: 100,
+ textAlignVertical: 'top',
+ color: theme.foreground,
+ },
+ screenshotButton: {
+ backgroundColor: theme.background,
+ padding: 15,
+ borderRadius: 5,
+ alignItems: 'center',
+ flex: 1,
+ borderWidth: 1,
+ borderColor: theme.border,
+ },
+ screenshotContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ width: '100%',
+ marginBottom: 20,
+ },
+ screenshotThumbnail: {
+ width: 50,
+ height: 50,
+ borderRadius: 5,
+ marginRight: 10,
+ },
+ screenshotText: {
+ color: theme.foreground,
+ fontSize: 16,
+ },
+ takeScreenshotButton: {
+ backgroundColor: theme.background,
+ padding: 15,
+ borderRadius: 5,
+ alignItems: 'center',
+ borderWidth: 1,
+ borderColor: theme.border,
+ marginTop: -10,
+ marginBottom: 20,
+ },
+ takeScreenshotText: {
+ color: theme.foreground,
+ fontSize: 16,
+ },
+ submitButton: {
+ backgroundColor: theme.accentBackground,
+ paddingVertical: 15,
+ borderRadius: 5,
+ alignItems: 'center',
+ marginBottom: 10,
+ },
+ submitText: {
+ color: theme.accentForeground,
+ fontSize: 18,
+ },
+ cancelButton: {
+ backgroundColor: theme.background,
+ padding: 15,
+ borderRadius: 5,
+ alignItems: 'center',
+ borderWidth: 1,
+ borderColor: theme.border,
+ },
+ cancelText: {
+ color: theme.foreground,
+ fontSize: 16,
+ },
+ titleContainer: {
+ flexDirection: 'row',
+ width: '100%',
+ },
+ sentryLogo: {
+ width: 40,
+ height: 40,
+ tintColor: theme.sentryLogo,
+ },
+ };
+};
-const defaultStyles: FeedbackWidgetStyles = {
- container: {
- flex: 1,
- padding: 20,
- backgroundColor: BACKGROUND_COLOR,
- },
- title: {
- fontSize: 24,
- fontWeight: 'bold',
- marginBottom: 20,
- textAlign: 'left',
- flex: 1,
- color: FOREGROUND_COLOR,
- },
- label: {
- marginBottom: 4,
- fontSize: 16,
- color: FOREGROUND_COLOR,
- },
- input: {
- height: 50,
- borderColor: BORDER_COLOR,
- borderWidth: 1,
- borderRadius: 5,
- paddingHorizontal: 10,
- marginBottom: 15,
- fontSize: 16,
- color: FOREGROUND_COLOR,
- },
- textArea: {
- height: 100,
- textAlignVertical: 'top',
- color: FOREGROUND_COLOR,
- },
- screenshotButton: {
- backgroundColor: BACKGROUND_COLOR,
- padding: 15,
- borderRadius: 5,
- alignItems: 'center',
- flex: 1,
- borderWidth: 1,
- borderColor: BORDER_COLOR,
- },
- screenshotContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- width: '100%',
- marginBottom: 20,
- },
- screenshotThumbnail: {
- width: 50,
- height: 50,
- borderRadius: 5,
- marginRight: 10,
- },
- screenshotText: {
- color: FOREGROUND_COLOR,
- fontSize: 16,
- },
- submitButton: {
- backgroundColor: PURPLE,
- paddingVertical: 15,
- borderRadius: 5,
- alignItems: 'center',
- marginBottom: 10,
- },
- submitText: {
- color: BACKGROUND_COLOR,
- fontSize: 18,
- },
- cancelButton: {
- backgroundColor: BACKGROUND_COLOR,
- padding: 15,
- borderRadius: 5,
- alignItems: 'center',
- borderWidth: 1,
- borderColor: BORDER_COLOR,
- },
- cancelText: {
- color: FOREGROUND_COLOR,
- fontSize: 16,
- },
- titleContainer: {
- flexDirection: 'row',
- width: '100%',
- },
- sentryLogo: {
- width: 40,
- height: 40,
- },
+export const defaultButtonStyles = (theme: FeedbackWidgetTheme): FeedbackButtonStyles => {
+ return {
+ triggerButton: {
+ position: 'absolute',
+ bottom: 30,
+ right: 30,
+ backgroundColor: theme.background,
+ padding: 15,
+ borderRadius: 40,
+ justifyContent: 'center',
+ alignItems: 'center',
+ elevation: 5,
+ shadowColor: theme.border,
+ shadowOffset: { width: 1, height: 2 },
+ shadowOpacity: 0.5,
+ shadowRadius: 3,
+ flexDirection: 'row',
+ borderWidth: 1,
+ borderColor: theme.border,
+ },
+ triggerText: {
+ color: theme.foreground,
+ fontSize: 18,
+ },
+ triggerIcon: {
+ width: 24,
+ height: 24,
+ padding: 2,
+ marginEnd: 6,
+ tintColor: theme.sentryLogo,
+ },
+ };
};
+export const defaultScreenshotButtonStyles = defaultButtonStyles;
+
export const modalWrapper: ViewStyle = {
position: 'absolute',
top: 0,
@@ -107,18 +156,20 @@ export const modalWrapper: ViewStyle = {
bottom: 0,
};
-export const modalSheetContainer: ViewStyle = {
- backgroundColor: '#ffffff',
- borderTopLeftRadius: 16,
- borderTopRightRadius: 16,
- overflow: 'hidden',
- alignSelf: 'stretch',
- shadowColor: '#000',
- shadowOffset: { width: 0, height: -3 },
- shadowOpacity: 0.1,
- shadowRadius: 4,
- elevation: 5,
- flex: 1,
+export const modalSheetContainer = (theme: FeedbackWidgetTheme): ViewStyle => {
+ return {
+ backgroundColor: theme.background,
+ borderTopLeftRadius: 16,
+ borderTopRightRadius: 16,
+ overflow: 'hidden',
+ alignSelf: 'stretch',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: -3 },
+ shadowOpacity: 0.1,
+ shadowRadius: 4,
+ elevation: 5,
+ flex: 1,
+ };
};
export const topSpacer: ViewStyle = {
diff --git a/packages/core/src/js/feedback/FeedbackWidget.theme.ts b/packages/core/src/js/feedback/FeedbackWidget.theme.ts
new file mode 100644
index 0000000000..aa8711a934
--- /dev/null
+++ b/packages/core/src/js/feedback/FeedbackWidget.theme.ts
@@ -0,0 +1,71 @@
+import { Appearance } from 'react-native';
+
+import { getColorScheme, getFeedbackDarkTheme, getFeedbackLightTheme } from './integration';
+
+/**
+ * Get the theme for the feedback widget based on the current color scheme
+ */
+export function getTheme(): FeedbackWidgetTheme {
+ const userTheme = getColorScheme();
+ const colorScheme = userTheme === 'system' ? Appearance.getColorScheme() : userTheme;
+ const lightTheme = { ...LightTheme, ...getFeedbackLightTheme() };
+ const darkTheme = { ...DarkTheme, ...getFeedbackDarkTheme() };
+ return colorScheme === 'dark' ? darkTheme : lightTheme;
+}
+
+export interface FeedbackWidgetTheme {
+ /**
+ * Background color for surfaces
+ */
+ background: string;
+
+ /**
+ * Foreground color (i.e. text color)
+ */
+ foreground: string;
+
+ /**
+ * Foreground color for accented elements
+ */
+ accentForeground?: string;
+
+ /**
+ * Background color for accented elements
+ */
+ accentBackground?: string;
+
+ /**
+ * Border color
+ */
+ border?: string;
+
+ /**
+ * Color for feedback icon
+ */
+ feedbackIcon?: string;
+
+ /**
+ * Color for Sentry logo
+ */
+ sentryLogo?: string;
+}
+
+export const LightTheme: FeedbackWidgetTheme = {
+ accentBackground: 'rgba(88, 74, 192, 1)',
+ accentForeground: '#ffffff',
+ foreground: '#2b2233',
+ background: '#ffffff',
+ border: 'rgba(41, 35, 47, 0.13)',
+ feedbackIcon: 'rgba(54, 45, 89, 1)',
+ sentryLogo: 'rgba(54, 45, 89, 1)',
+};
+
+export const DarkTheme: FeedbackWidgetTheme = {
+ accentBackground: 'rgba(88, 74, 192, 1)',
+ accentForeground: '#ffffff',
+ foreground: '#ebe6ef',
+ background: '#29232f',
+ border: 'rgba(235, 230, 239, 0.15)',
+ feedbackIcon: '#ffffff',
+ sentryLogo: '#ffffff',
+};
diff --git a/packages/core/src/js/feedback/FeedbackWidget.tsx b/packages/core/src/js/feedback/FeedbackWidget.tsx
index a7b9fe62ea..b84dd3b1b9 100644
--- a/packages/core/src/js/feedback/FeedbackWidget.tsx
+++ b/packages/core/src/js/feedback/FeedbackWidget.tsx
@@ -1,8 +1,11 @@
+/* eslint-disable max-lines */
import type { SendFeedbackParams } from '@sentry/core';
import { captureFeedback, getCurrentScope, lastEventId, logger } from '@sentry/core';
import * as React from 'react';
-import type { KeyboardTypeOptions } from 'react-native';
+import type { KeyboardTypeOptions ,
+ NativeEventSubscription} from 'react-native';
import {
+ Appearance,
Image,
Keyboard,
Text,
@@ -12,13 +15,17 @@ import {
View
} from 'react-native';
-import { isWeb, notWeb } from '../utils/environment';
-import { NATIVE } from '../wrapper';
+import { isExpoGo, isWeb, notWeb } from '../utils/environment';
+import type { Screenshot } from '../wrapper';
+import { getDataFromUri, NATIVE } from '../wrapper';
import { sentryLogo } from './branding';
import { defaultConfiguration } from './defaults';
import defaultStyles from './FeedbackWidget.styles';
+import { getTheme } from './FeedbackWidget.theme';
import type { FeedbackGeneralConfiguration, FeedbackTextConfiguration, FeedbackWidgetProps, FeedbackWidgetState, FeedbackWidgetStyles, ImagePickerConfiguration } from './FeedbackWidget.types';
+import { hideFeedbackButton, showScreenshotButton } from './FeedbackWidgetManager';
import { lazyLoadFeedbackIntegration } from './lazy';
+import { getCapturedScreenshot } from './ScreenshotButton';
import { base64ToUint8Array, feedbackAlertDialog, isValidEmail } from './utils';
/**
@@ -39,6 +46,8 @@ export class FeedbackWidget extends React.Component void = () => {
const { name, email, description } = this.state;
const { onSubmitSuccess, onSubmitError, onFormSubmitted } = this.props;
@@ -118,7 +141,7 @@ export class FeedbackWidget extends React.Component void = async () => {
- if (!this.state.filename && !this.state.attachment) {
+ if (!this._hasScreenshot()) {
const imagePickerConfiguration: ImagePickerConfiguration = this.props;
if (imagePickerConfiguration.imagePicker) {
const launchImageLibrary = imagePickerConfiguration.imagePicker.launchImageLibraryAsync
@@ -154,13 +177,15 @@ export class FeedbackWidget extends React.Component {
+ getDataFromUri(imageUri).then((data) => {
if (data != null) {
this.setState({ filename, attachment: data, attachmentUri: imageUri });
} else {
+ this._showImageRetrievalDevelopmentNote();
logger.error('Failed to read image data from uri:', imageUri);
}
}).catch((error) => {
+ this._showImageRetrievalDevelopmentNote();
logger.error('Failed to read image data from uri:', imageUri, 'error: ', error);
});
}
@@ -169,14 +194,15 @@ export class FeedbackWidget extends React.Component {
- NATIVE.getDataFromUri(uri).then((data) => {
+ getDataFromUri(uri).then((data) => {
if (data != null) {
this.setState({ filename: 'feedback_screenshot', attachment: data, attachmentUri: uri });
} else {
+ this._showImageRetrievalDevelopmentNote();
logger.error('Failed to read image data from uri:', uri);
}
- })
- .catch((error) => {
+ }).catch((error) => {
+ this._showImageRetrievalDevelopmentNote();
logger.error('Failed to read image data from uri:', uri, 'error: ', error);
});
});
@@ -187,7 +213,16 @@ export class FeedbackWidget extends React.Component {
+ this.forceUpdate();
+ });
+ }
+
+ /**
+ * Save the state before unmounting the component and remove the theme listener.
*/
public componentWillUnmount(): void {
if (this._didSubmitForm) {
@@ -196,18 +231,22 @@ export class FeedbackWidget extends React.Component {
if (onFormClose) {
onFormClose();
@@ -220,11 +259,24 @@ export class FeedbackWidget extends React.Component {
+ feedbackAlertDialog(text.errorTitle, text.captureScreenshotError);
+ }, 100);
+ } else if (screenshot) {
+ this._setCapturedScreenshot(screenshot);
+ }
+
return (
-
+
- {text.formTitle}
+ {text.formTitle}
{config.showBranding && (
this.setState({ name: value })}
@@ -257,6 +310,7 @@ export class FeedbackWidget extends React.Component
this.setState({ description: value })}
multiline
/>
- {(config.enableScreenshot || imagePickerConfiguration.imagePicker) && (
+ {(config.enableScreenshot || imagePickerConfiguration.imagePicker || this._hasScreenshot()) && (
{this.state.attachmentUri && (
- {!this.state.filename && !this.state.attachment
+ {!this._hasScreenshot()
? text.addScreenshotButtonLabel
: text.removeScreenshotButtonLabel}
)}
+ {notWeb() && config.enableTakeScreenshot && !this.state.attachmentUri && (
+ {
+ hideFeedbackButton();
+ onCancel();
+ showScreenshotButton();
+ }}>
+ {text.captureScreenshotButtonLabel}
+
+ )}
- {text.submitButtonLabel}
+ {text.submitButtonLabel}
@@ -305,6 +369,24 @@ export class FeedbackWidget extends React.Component {
+ if (screenshot.data != null) {
+ logger.debug('Setting captured screenshot:', screenshot.filename);
+ NATIVE.encodeToBase64(screenshot.data).then((base64String) => {
+ if (base64String != null) {
+ const dataUri = `data:${screenshot.contentType};base64,${base64String}`;
+ this.setState({ filename: screenshot.filename, attachment: screenshot.data, attachmentUri: dataUri });
+ } else {
+ logger.error('Failed to read image data from:', screenshot.filename);
+ }
+ }).catch((error) => {
+ logger.error('Failed to read image data from:', screenshot.filename, 'error: ', error);
+ });
+ } else {
+ logger.error('Failed to read image data from:', screenshot.filename);
+ }
+ }
+
private _saveFormState = (): void => {
FeedbackWidget._savedState = { ...this.state };
};
@@ -319,4 +401,17 @@ export class FeedbackWidget extends React.Component {
+ return this.state.filename !== undefined && this.state.attachment !== undefined && this.state.attachmentUri !== undefined;
+ }
+
+ private _showImageRetrievalDevelopmentNote = (): void => {
+ if (isExpoGo()) {
+ feedbackAlertDialog(
+ 'Development note',
+ 'The feedback widget cannot retrieve image data in Expo Go. Please build your app to test this functionality.',
+ );
+ }
+ }
}
diff --git a/packages/core/src/js/feedback/FeedbackWidget.types.ts b/packages/core/src/js/feedback/FeedbackWidget.types.ts
index af08c2ffc3..22b6b0911f 100644
--- a/packages/core/src/js/feedback/FeedbackWidget.types.ts
+++ b/packages/core/src/js/feedback/FeedbackWidget.types.ts
@@ -54,6 +54,12 @@ export interface FeedbackGeneralConfiguration {
*/
enableScreenshot?: boolean;
+ /**
+ * This flag determines whether the "Take Screenshot" button is displayed
+ * @default false
+ */
+ enableTakeScreenshot?: boolean;
+
/**
* Fill in email/name input fields with Sentry user context if it exists.
* The value of the email/name keys represent the properties of your user context.
@@ -124,15 +130,20 @@ export interface FeedbackTextConfiguration {
isRequiredLabel?: string;
/**
- * The label for the button that adds a screenshot and renders the image editor
+ * The label for the button that adds a screenshot
*/
addScreenshotButtonLabel?: string;
/**
- * The label for the button that removes a screenshot and hides the image editor
+ * The label for the button that removes a screenshot
*/
removeScreenshotButtonLabel?: string;
+ /**
+ * The label for the button that shows the capture screenshot button
+ */
+ captureScreenshotButtonLabel?: string;
+
/**
* The title of the error dialog
*/
@@ -148,12 +159,47 @@ export interface FeedbackTextConfiguration {
*/
emailError?: string;
+ /**
+ * The error message when the capture screenshot fails
+ */
+ captureScreenshotError?: string;
+
/**
* Message when there is a generic error
*/
genericError?: string;
}
+/**
+ * The FeedbackButton text labels that can be customized
+ */
+export interface FeedbackButtonTextConfiguration {
+ /**
+ * The label for the Feedback widget button that opens the dialog
+ */
+ triggerLabel?: string;
+
+ /**
+ * The aria label for the Feedback widget button that opens the dialog
+ */
+ triggerAriaLabel?: string;
+}
+
+/**
+ * The ScreenshotButton text labels that can be customized
+ */
+export interface ScreenshotButtonTextConfiguration {
+ /**
+ * The label for the Screenshot button
+ */
+ triggerLabel?: string;
+
+ /**
+ * The aria label for the Screenshot button
+ */
+ triggerAriaLabel?: string;
+}
+
/**
* The public callbacks available for the feedback integration
*/
@@ -243,10 +289,44 @@ export interface FeedbackWidgetStyles {
screenshotContainer?: ViewStyle;
screenshotThumbnail?: ImageStyle;
screenshotText?: TextStyle;
+ takeScreenshotButton?: ViewStyle;
+ takeScreenshotText?: TextStyle;
titleContainer?: ViewStyle;
sentryLogo?: ImageStyle;
}
+/**
+ * The props for the feedback button
+ */
+export interface FeedbackButtonProps extends FeedbackButtonTextConfiguration {
+ styles?: FeedbackButtonStyles;
+}
+
+/**
+ * The styles for the feedback button
+ */
+export interface FeedbackButtonStyles {
+ triggerButton?: ViewStyle;
+ triggerText?: TextStyle;
+ triggerIcon?: ImageStyle;
+}
+
+/**
+ * The props for the screenshot button
+ */
+export interface ScreenshotButtonProps extends ScreenshotButtonTextConfiguration {
+ styles?: ScreenshotButtonStyles;
+}
+
+/**
+ * The styles for the screenshot button
+ */
+export interface ScreenshotButtonStyles {
+ triggerButton?: ViewStyle;
+ triggerText?: TextStyle;
+ triggerIcon?: ImageStyle;
+}
+
/**
* The state of the feedback form
*/
diff --git a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx
index 856298382e..e554715586 100644
--- a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx
+++ b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx
@@ -1,23 +1,19 @@
import { logger } from '@sentry/core';
-import * as React from 'react';
-import type { NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
-import { Animated, Dimensions, Easing, Modal, PanResponder, Platform, ScrollView, View } from 'react-native';
-
-import { notWeb } from '../utils/environment';
-import { FeedbackWidget } from './FeedbackWidget';
-import { modalSheetContainer, modalWrapper, topSpacer } from './FeedbackWidget.styles';
-import type { FeedbackWidgetStyles } from './FeedbackWidget.types';
-import { getFeedbackOptions } from './integration';
-import { lazyLoadAutoInjectFeedbackIntegration } from './lazy';
-import { isModalSupported } from './utils';
-
-const PULL_DOWN_CLOSE_THRESHOLD = 200;
-const SLIDE_ANIMATION_DURATION = 200;
-const BACKGROUND_ANIMATION_DURATION = 200;
-
-class FeedbackWidgetManager {
- private static _isVisible = false;
- private static _setVisibility: (visible: boolean) => void;
+
+import { isWeb } from '../utils/environment';
+import { lazyLoadAutoInjectFeedbackButtonIntegration,lazyLoadAutoInjectFeedbackIntegration, lazyLoadAutoInjectScreenshotButtonIntegration } from './lazy';
+
+export const PULL_DOWN_CLOSE_THRESHOLD = 200;
+export const SLIDE_ANIMATION_DURATION = 200;
+export const BACKGROUND_ANIMATION_DURATION = 200;
+
+abstract class FeedbackManager {
+ protected static _isVisible = false;
+ protected static _setVisibility: (visible: boolean) => void;
+
+ protected static get _feedbackComponentName(): string {
+ throw new Error('Subclasses must override feedbackComponentName');
+ }
public static initialize(setVisibility: (visible: boolean) => void): void {
this._setVisibility = setVisibility;
@@ -38,7 +34,7 @@ class FeedbackWidgetManager {
} else {
// This message should be always shown otherwise it's not possible to use the widget.
// eslint-disable-next-line no-console
- console.warn('[Sentry] FeedbackWidget requires `Sentry.wrap(RootComponent)` to be called before `showFeedbackWidget()`.');
+ console.warn(`[Sentry] ${this._feedbackComponentName} requires 'Sentry.wrap(RootComponent)' to be called before 'show${this._feedbackComponentName}()'.`);
}
}
@@ -49,7 +45,7 @@ class FeedbackWidgetManager {
} else {
// This message should be always shown otherwise it's not possible to use the widget.
// eslint-disable-next-line no-console
- console.warn('[Sentry] FeedbackWidget requires `Sentry.wrap(RootComponent)` before interacting with the widget.');
+ console.warn(`[Sentry] ${this._feedbackComponentName} requires 'Sentry.wrap(RootComponent)' before interacting with the widget.`);
}
}
@@ -58,170 +54,40 @@ class FeedbackWidgetManager {
}
}
-interface FeedbackWidgetProviderProps {
- children: React.ReactNode;
- styles?: FeedbackWidgetStyles;
-}
-
-interface FeedbackWidgetProviderState {
- isVisible: boolean;
- backgroundOpacity: Animated.Value;
- panY: Animated.Value;
- isScrollAtTop: boolean;
-}
-
-class FeedbackWidgetProvider extends React.Component {
- public state: FeedbackWidgetProviderState = {
- isVisible: false,
- backgroundOpacity: new Animated.Value(0),
- panY: new Animated.Value(Dimensions.get('screen').height),
- isScrollAtTop: true,
- };
-
- private _panResponder = PanResponder.create({
- onStartShouldSetPanResponder: (_, gestureState) => {
- return notWeb() && this.state.isScrollAtTop && gestureState.dy > 0;
- },
- onMoveShouldSetPanResponder: (_, gestureState) => {
- return notWeb() && this.state.isScrollAtTop && gestureState.dy > 0;
- },
- onPanResponderMove: (_, gestureState) => {
- if (gestureState.dy > 0) {
- this.state.panY.setValue(gestureState.dy);
- }
- },
- onPanResponderRelease: (_, gestureState) => {
- if (gestureState.dy > PULL_DOWN_CLOSE_THRESHOLD) {
- // Close on swipe below a certain threshold
- Animated.timing(this.state.panY, {
- toValue: Dimensions.get('screen').height,
- duration: SLIDE_ANIMATION_DURATION,
- useNativeDriver: true,
- }).start(() => {
- this._handleClose();
- });
- } else {
- // Animate it back to the original position
- Animated.spring(this.state.panY, {
- toValue: 0,
- useNativeDriver: true,
- }).start();
- }
- },
- });
-
- public constructor(props: FeedbackWidgetProviderProps) {
- super(props);
- FeedbackWidgetManager.initialize(this._setVisibilityFunction);
+/**
+ * Provides functionality to show and hide the feedback widget.
+ */
+export class FeedbackWidgetManager extends FeedbackManager {
+ /**
+ * Returns the name of the feedback component.
+ */
+ protected static get _feedbackComponentName(): string {
+ return 'FeedbackWidget';
}
+}
+/**
+ * Provides functionality to show and hide the feedback button.
+ */
+export class FeedbackButtonManager extends FeedbackManager {
/**
- * Animates the background opacity when the modal is shown.
+ * Returns the name of the feedback component.
*/
- public componentDidUpdate(_prevProps: any, prevState: FeedbackWidgetProviderState): void {
- if (!prevState.isVisible && this.state.isVisible) {
- Animated.parallel([
- Animated.timing(this.state.backgroundOpacity, {
- toValue: 1,
- duration: BACKGROUND_ANIMATION_DURATION,
- useNativeDriver: true,
- easing: Easing.in(Easing.quad),
- }),
- Animated.timing(this.state.panY, {
- toValue: 0,
- duration: SLIDE_ANIMATION_DURATION,
- useNativeDriver: true,
- easing: Easing.in(Easing.quad),
- })
- ]).start(() => {
- logger.info('FeedbackWidgetProvider componentDidUpdate');
- });
- } else if (prevState.isVisible && !this.state.isVisible) {
- this.state.backgroundOpacity.setValue(0);
- }
+ protected static get _feedbackComponentName(): string {
+ return 'FeedbackButton';
}
+}
+/**
+ * Provides functionality to show and hide the screenshot button.
+ */
+export class ScreenshotButtonManager extends FeedbackManager {
/**
- * Renders the feedback form modal.
+ * Returns the name of the feedback component.
*/
- public render(): React.ReactNode {
- if (!isModalSupported()) {
- logger.error('FeedbackWidget Modal is not supported in React Native < 0.71 with Fabric renderer.');
- return <>{this.props.children}>;
- }
-
- const { isVisible, backgroundOpacity } = this.state;
-
- const backgroundColor = backgroundOpacity.interpolate({
- inputRange: [0, 1],
- outputRange: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0.9)'],
- });
-
- // Wrapping the `Modal` component in a `View` component is necessary to avoid
- // issues like https://github.com/software-mansion/react-native-reanimated/issues/6035
- return (
- <>
- {this.props.children}
- {isVisible &&
-
-
-
-
-
-
-
-
-
-
- }
- >
- );
+ protected static get _feedbackComponentName(): string {
+ return 'ScreenshotButton';
}
-
- private _handleScroll = (event: NativeSyntheticEvent): void => {
- this.setState({ isScrollAtTop: event.nativeEvent.contentOffset.y <= 0 });
- };
-
- private _setVisibilityFunction = (visible: boolean): void => {
- const updateState = (): void => {
- this.setState({ isVisible: visible });
- };
- if (!visible) {
- Animated.parallel([
- Animated.timing(this.state.panY, {
- toValue: Dimensions.get('screen').height,
- duration: SLIDE_ANIMATION_DURATION,
- useNativeDriver: true,
- easing: Easing.out(Easing.quad),
- }),
- Animated.timing(this.state.backgroundOpacity, {
- toValue: 0,
- duration: BACKGROUND_ANIMATION_DURATION,
- useNativeDriver: true,
- easing: Easing.out(Easing.quad),
- })
- ]).start(() => {
- // Change of the state unmount the component
- // which would cancel the animation
- updateState();
- });
- } else {
- updateState();
- }
- };
-
- private _handleClose = (): void => {
- FeedbackWidgetManager.hide();
- };
}
const showFeedbackWidget = (): void => {
@@ -233,4 +99,34 @@ const resetFeedbackWidgetManager = (): void => {
FeedbackWidgetManager.reset();
};
-export { showFeedbackWidget, FeedbackWidgetProvider, resetFeedbackWidgetManager };
+const showFeedbackButton = (): void => {
+ lazyLoadAutoInjectFeedbackButtonIntegration();
+ FeedbackButtonManager.show();
+};
+
+const hideFeedbackButton = (): void => {
+ FeedbackButtonManager.hide();
+};
+
+const resetFeedbackButtonManager = (): void => {
+ FeedbackButtonManager.reset();
+};
+
+const showScreenshotButton = (): void => {
+ if (isWeb()) {
+ logger.warn('ScreenshotButton is not supported on Web.');
+ return;
+ }
+ lazyLoadAutoInjectScreenshotButtonIntegration();
+ ScreenshotButtonManager.show();
+};
+
+const hideScreenshotButton = (): void => {
+ ScreenshotButtonManager.hide();
+};
+
+const resetScreenshotButtonManager = (): void => {
+ ScreenshotButtonManager.reset();
+};
+
+export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager };
diff --git a/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx b/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx
new file mode 100644
index 0000000000..9e90ed785f
--- /dev/null
+++ b/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx
@@ -0,0 +1,223 @@
+import { logger } from '@sentry/core';
+import * as React from 'react';
+import { type NativeEventSubscription, type NativeScrollEvent,type NativeSyntheticEvent, Animated, Appearance, Dimensions, Easing, Modal, PanResponder, Platform, ScrollView, View } from 'react-native';
+
+import { notWeb } from '../utils/environment';
+import { FeedbackButton } from './FeedbackButton';
+import { FeedbackWidget } from './FeedbackWidget';
+import { modalSheetContainer,modalWrapper, topSpacer } from './FeedbackWidget.styles';
+import { getTheme } from './FeedbackWidget.theme';
+import type { FeedbackWidgetStyles } from './FeedbackWidget.types';
+import { BACKGROUND_ANIMATION_DURATION,FeedbackButtonManager, FeedbackWidgetManager, PULL_DOWN_CLOSE_THRESHOLD, ScreenshotButtonManager, SLIDE_ANIMATION_DURATION } from './FeedbackWidgetManager';
+import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions } from './integration';
+import { ScreenshotButton } from './ScreenshotButton';
+import { isModalSupported, isNativeDriverSupportedForColorAnimations } from './utils';
+
+const useNativeDriverForColorAnimations = isNativeDriverSupportedForColorAnimations();
+
+export interface FeedbackWidgetProviderProps {
+ children: React.ReactNode;
+ styles?: FeedbackWidgetStyles;
+}
+
+export interface FeedbackWidgetProviderState {
+ isButtonVisible: boolean;
+ isScreenshotButtonVisible: boolean;
+ isVisible: boolean;
+ backgroundOpacity: Animated.Value;
+ panY: Animated.Value;
+ isScrollAtTop: boolean;
+}
+
+/**
+ * FeedbackWidgetProvider is a component that wraps the feedback widget and provides
+ * functionality to show and hide the widget. It also manages the visibility of the
+ * feedback button and screenshot button.
+ */
+export class FeedbackWidgetProvider extends React.Component {
+ public state: FeedbackWidgetProviderState = {
+ isButtonVisible: false,
+ isScreenshotButtonVisible: false,
+ isVisible: false,
+ backgroundOpacity: new Animated.Value(0),
+ panY: new Animated.Value(Dimensions.get('screen').height),
+ isScrollAtTop: true,
+ };
+
+ private _themeListener: NativeEventSubscription;
+
+ private _panResponder = PanResponder.create({
+ onStartShouldSetPanResponder: (_, gestureState) => {
+ return notWeb() && this.state.isScrollAtTop && gestureState.dy > 0;
+ },
+ onMoveShouldSetPanResponder: (_, gestureState) => {
+ return notWeb() && this.state.isScrollAtTop && gestureState.dy > 0;
+ },
+ onPanResponderMove: (_, gestureState) => {
+ if (gestureState.dy > 0) {
+ this.state.panY.setValue(gestureState.dy);
+ }
+ },
+ onPanResponderRelease: (_, gestureState) => {
+ if (gestureState.dy > PULL_DOWN_CLOSE_THRESHOLD) {
+ // Close on swipe below a certain threshold
+ Animated.timing(this.state.panY, {
+ toValue: Dimensions.get('screen').height,
+ duration: SLIDE_ANIMATION_DURATION,
+ useNativeDriver: true,
+ }).start(() => {
+ this._handleClose();
+ });
+ } else {
+ // Animate it back to the original position
+ Animated.spring(this.state.panY, {
+ toValue: 0,
+ useNativeDriver: true,
+ }).start();
+ }
+ },
+ });
+
+ public constructor(props: FeedbackWidgetProviderProps) {
+ super(props);
+ FeedbackButtonManager.initialize(this._setButtonVisibilityFunction);
+ ScreenshotButtonManager.initialize(this._setScreenshotButtonVisibilityFunction);
+ FeedbackWidgetManager.initialize(this._setVisibilityFunction);
+ }
+
+ /**
+ * Add a listener to the theme change event.
+ */
+ public componentDidMount(): void {
+ this._themeListener = Appearance.addChangeListener(() => {
+ this.forceUpdate();
+ });
+ }
+
+ /**
+ * Clean up the theme listener.
+ */
+ public componentWillUnmount(): void {
+ if (this._themeListener) {
+ this._themeListener.remove();
+ }
+ }
+
+ /**
+ * Animates the background opacity when the modal is shown.
+ */
+ public componentDidUpdate(_prevProps: any, prevState: FeedbackWidgetProviderState): void {
+ if (!prevState.isVisible && this.state.isVisible) {
+ Animated.parallel([
+ Animated.timing(this.state.backgroundOpacity, {
+ toValue: 1,
+ duration: BACKGROUND_ANIMATION_DURATION,
+ useNativeDriver: useNativeDriverForColorAnimations,
+ easing: Easing.in(Easing.quad),
+ }),
+ Animated.timing(this.state.panY, {
+ toValue: 0,
+ duration: SLIDE_ANIMATION_DURATION,
+ useNativeDriver: true,
+ easing: Easing.in(Easing.quad),
+ })
+ ]).start(() => {
+ logger.info('FeedbackWidgetProvider componentDidUpdate');
+ });
+ } else if (prevState.isVisible && !this.state.isVisible) {
+ this.state.backgroundOpacity.setValue(0);
+ }
+ }
+
+ /**
+ * Renders the feedback form modal.
+ */
+ public render(): React.ReactNode {
+ if (!isModalSupported()) {
+ logger.error('FeedbackWidget Modal is not supported in React Native < 0.71 with Fabric renderer.');
+ return <>{this.props.children}>;
+ }
+
+ const theme = getTheme();
+
+ const { isButtonVisible, isScreenshotButtonVisible, isVisible, backgroundOpacity } = this.state;
+
+ const backgroundColor = backgroundOpacity.interpolate({
+ inputRange: [0, 1],
+ outputRange: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0.9)'],
+ });
+
+ // Wrapping the `Modal` component in a `View` component is necessary to avoid
+ // issues like https://github.com/software-mansion/react-native-reanimated/issues/6035
+ return (
+ <>
+ {this.props.children}
+ {isButtonVisible && }
+ {isScreenshotButtonVisible && }
+ {isVisible &&
+
+
+
+
+
+
+
+
+
+ }
+ >
+ );
+ }
+
+ private _handleScroll = (event: NativeSyntheticEvent): void => {
+ this.setState({ isScrollAtTop: event.nativeEvent.contentOffset.y <= 0 });
+ };
+
+ private _setVisibilityFunction = (visible: boolean): void => {
+ const updateState = (): void => {
+ this.setState({ isVisible: visible });
+ };
+ if (!visible) {
+ Animated.parallel([
+ Animated.timing(this.state.panY, {
+ toValue: Dimensions.get('screen').height,
+ duration: SLIDE_ANIMATION_DURATION,
+ useNativeDriver: true,
+ easing: Easing.out(Easing.quad),
+ }),
+ Animated.timing(this.state.backgroundOpacity, {
+ toValue: 0,
+ duration: BACKGROUND_ANIMATION_DURATION,
+ useNativeDriver: useNativeDriverForColorAnimations,
+ easing: Easing.out(Easing.quad),
+ })
+ ]).start(() => {
+ // Change of the state unmount the component
+ // which would cancel the animation
+ updateState();
+ });
+ } else {
+ updateState();
+ }
+ };
+
+ private _setButtonVisibilityFunction = (visible: boolean): void => {
+ this.setState({ isButtonVisible: visible });
+ };
+
+ private _setScreenshotButtonVisibilityFunction = (visible: boolean): void => {
+ this.setState({ isScreenshotButtonVisible: visible });
+ };
+
+ private _handleClose = (): void => {
+ FeedbackWidgetManager.hide();
+ };
+}
diff --git a/packages/core/src/js/feedback/ScreenshotButton.tsx b/packages/core/src/js/feedback/ScreenshotButton.tsx
new file mode 100644
index 0000000000..18cfa19239
--- /dev/null
+++ b/packages/core/src/js/feedback/ScreenshotButton.tsx
@@ -0,0 +1,89 @@
+import * as React from 'react';
+import type { NativeEventSubscription} from 'react-native';
+import { Appearance, Image, Text, TouchableOpacity } from 'react-native';
+
+import type { Screenshot } from '../wrapper';
+import { NATIVE } from '../wrapper';
+import { defaultScreenshotButtonConfiguration } from './defaults';
+import { defaultScreenshotButtonStyles } from './FeedbackWidget.styles';
+import { getTheme } from './FeedbackWidget.theme';
+import type { ScreenshotButtonProps, ScreenshotButtonStyles, ScreenshotButtonTextConfiguration } from './FeedbackWidget.types';
+import { hideScreenshotButton, showFeedbackWidget } from './FeedbackWidgetManager';
+import { screenshotIcon } from './icons';
+import { lazyLoadFeedbackIntegration } from './lazy';
+
+let capturedScreenshot: Screenshot | 'ErrorCapturingScreenshot' | undefined;
+
+const takeScreenshot = async (): Promise => {
+ hideScreenshotButton();
+ setTimeout(async () => { // Delay capture to allow the button to hide
+ const screenshots: Screenshot[] | null = await NATIVE.captureScreenshot();
+ if (screenshots && screenshots.length > 0) {
+ capturedScreenshot = screenshots[0];
+ } else {
+ capturedScreenshot = 'ErrorCapturingScreenshot';
+ }
+ showFeedbackWidget();
+ }, 100);
+};
+
+export const getCapturedScreenshot = (): Screenshot | 'ErrorCapturingScreenshot' | undefined => {
+ const screenshot = capturedScreenshot;
+ capturedScreenshot = undefined;
+ return screenshot;
+}
+
+/**
+ * @beta
+ * Implements a screenshot button that takes a screenshot.
+ */
+export class ScreenshotButton extends React.Component {
+ private _themeListener: NativeEventSubscription;
+
+ public constructor(props: ScreenshotButtonProps) {
+ super(props);
+ lazyLoadFeedbackIntegration();
+ }
+
+ /**
+ * Adds a listener for theme changes.
+ */
+ public componentDidMount(): void {
+ this._themeListener = Appearance.addChangeListener(() => {
+ this.forceUpdate();
+ });
+ }
+
+ /**
+ * Removes the theme listener.
+ */
+ public componentWillUnmount(): void {
+ if (this._themeListener) {
+ this._themeListener.remove();
+ }
+ }
+
+ /**
+ * Renders the screenshot button.
+ */
+ public render(): React.ReactNode {
+ const theme = getTheme();
+ const text: ScreenshotButtonTextConfiguration = { ...defaultScreenshotButtonConfiguration, ...this.props };
+ const styles: ScreenshotButtonStyles = {
+ triggerButton: { ...defaultScreenshotButtonStyles(theme).triggerButton, ...this.props.styles?.triggerButton },
+ triggerText: { ...defaultScreenshotButtonStyles(theme).triggerText, ...this.props.styles?.triggerText },
+ triggerIcon: { ...defaultScreenshotButtonStyles(theme).triggerIcon, ...this.props.styles?.triggerIcon },
+ };
+
+ return (
+
+
+ {text.triggerLabel}
+
+ );
+ }
+}
diff --git a/packages/core/src/js/feedback/defaults.ts b/packages/core/src/js/feedback/defaults.ts
index 90d9534874..2158b69a41 100644
--- a/packages/core/src/js/feedback/defaults.ts
+++ b/packages/core/src/js/feedback/defaults.ts
@@ -1,4 +1,4 @@
-import type { FeedbackWidgetProps } from './FeedbackWidget.types';
+import type { FeedbackButtonProps, FeedbackWidgetProps, ScreenshotButtonProps } from './FeedbackWidget.types';
import { feedbackAlertDialog } from './utils';
const FORM_TITLE = 'Report a Bug';
@@ -11,11 +11,15 @@ const MESSAGE_LABEL = 'Description';
const IS_REQUIRED_LABEL = '(required)';
const SUBMIT_BUTTON_LABEL = 'Send Bug Report';
const CANCEL_BUTTON_LABEL = 'Cancel';
+const TRIGGER_LABEL = 'Report a Bug';
+const TRIGGER_SCREENSHOT_LABEL = 'Take Screenshot';
const ERROR_TITLE = 'Error';
const FORM_ERROR = 'Please fill out all required fields.';
const EMAIL_ERROR = 'Please enter a valid email address.';
+const CAPTURE_SCREENSHOT_ERROR = 'Error capturing screenshot. Please try again.';
const SUCCESS_MESSAGE_TEXT = 'Thank you for your report!';
const ADD_SCREENSHOT_LABEL = 'Add a screenshot';
+const CAPTURE_SCREENSHOT_LABEL = 'Take a screenshot';
const REMOVE_SCREENSHOT_LABEL = 'Remove screenshot';
const GENERIC_ERROR_TEXT = 'Unable to send feedback due to an unexpected error.';
@@ -60,6 +64,7 @@ export const defaultConfiguration: Partial = {
showEmail: true,
showName: true,
enableScreenshot: false,
+ enableTakeScreenshot: false,
// FeedbackTextConfiguration
cancelButtonLabel: CANCEL_BUTTON_LABEL,
@@ -75,8 +80,20 @@ export const defaultConfiguration: Partial = {
errorTitle: ERROR_TITLE,
formError: FORM_ERROR,
emailError: EMAIL_ERROR,
+ captureScreenshotError: CAPTURE_SCREENSHOT_ERROR,
successMessageText: SUCCESS_MESSAGE_TEXT,
addScreenshotButtonLabel: ADD_SCREENSHOT_LABEL,
removeScreenshotButtonLabel: REMOVE_SCREENSHOT_LABEL,
+ captureScreenshotButtonLabel: CAPTURE_SCREENSHOT_LABEL,
genericError: GENERIC_ERROR_TEXT,
};
+
+export const defaultButtonConfiguration: Partial = {
+ triggerLabel: TRIGGER_LABEL,
+ triggerAriaLabel: '',
+};
+
+export const defaultScreenshotButtonConfiguration: Partial = {
+ triggerLabel: TRIGGER_SCREENSHOT_LABEL,
+ triggerAriaLabel: '',
+};
diff --git a/packages/core/src/js/feedback/icons.ts b/packages/core/src/js/feedback/icons.ts
new file mode 100644
index 0000000000..b73ecdde86
--- /dev/null
+++ b/packages/core/src/js/feedback/icons.ts
@@ -0,0 +1,32 @@
+export const feedbackIcon =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAMAAABrrFhUAAAAk1BMVEUAAAAqIDEqIjMmIDArIjIrIjMoHjErITMrIjMqITIqITIqITIqIjIqITIqITIhHi4qITIqITIeAConHi8rITMqITIqITIcHCopITIpIDEpIDIlGC8qITIqITIpIDIpHjAqITIqIDIqIDEqIDAqITInHjAqIDIqIDEqITIqITIqIDIiFCgpIDEqITIoHS8qIDIrIjMN1S0HAAAAMHRSTlMAhnknzMQy1zyF6/H55PYQ0rIGGPudjAmUTEQUv7p2K6lmWDZxHV9S36J+DD7bI208pRBPAAAGrUlEQVR42uzd6XLaMBSG4Y/YhuAVbAwx+xp2eu7/6tq0nfaHIAizSNbR+5vJJM8EG45lGfrm7EZRSNL5bvcAg1oM6fZaBQxp/0HlylOY0PuRyjbco/L1unRHbVS9RYPuaoxKlzY7dF9xhgpXb9Hd1VDddiHdX4SqNnHpIU1QzcYhPaY5qpjj0aNaooL9iOlhvaFynYZEnAFmAXEGWI2IOAN8BsQZIGsTcQaY+0SMAQZT+hVfgLVPX3EFSLb0J6YARUR/YwmQ5PQvjgD9DcmWJ7hUraoANwy9owIwDuCGofc2gXEANwy9/TVgHMANQ+/pAMYBpLUOSebPAeMAbhh6tzOYByA/9A4+AeMAbhh6j1YwD0B+6B3MAOMAbhh6D09QAdCvtT33ecUkWfwDeD1Ab+yTHnknKABYHEmPwjGgAKAISY/cCVQAvGvy94c7QAVAEpEWtepQA/BBOtRppngYwDaDfAMt3gCNBVAeQKzxUciC/iAN6vZwJ4BYsC0gU5uUd3wHHgAgFu0yXE39IfCjhwcBiHWmE1yJFLfpA+UBJJpOtAbI93gwgFieaQsQFcDzAShYagqwTfAUADF3oiGAvwZeBUDxUjuA6QBPBBAbDbQC8OfAawFoU9cIoJ3h5QAUz3UBCD4BBQBESz0ARisoAqCmBgDBDFAGQB/KAaYrqASgrlKAVtcB1ALQThJgXX94px4A5QD0KQfgQP/KAYQH5gDkZ8wByOMOQE3DAfJZHtF3dd7NBngDsKh9Z9BIjQf41dyjizU5AACHFl0odFgAADOfztdmAoBsROfrMwEAxh06l8cGAEVM5+qzAcDCpzMN+QBgEtCZ6nwAcAhJbMsIAHMSixNGAPggsRkngF6DhDxOAKiHJJRxAjj3JvjBCmAgngvbrACwE88DKSuARPxIfGAFgFx8JS+AungQ4AUAYUAUMQMQ79pNeAH0hZe+8wJIY+FKKS8AuMLFcmYAubBihBnAUliwwgxgLQwGmQEIpwGXGcBCWLXEDGAiXCZmBlDn/h9w4H4MKIS5KDOAmfB9mBmA8HUwZwYgLBaoMQOIhItDvAAccZEAL4Cl8NIVLwDhEBDwmgmKeyV4vADG4v0jvACO4m0hrAAKEspYAYjrZl1WV4c/SajGCWAfkdCCE0BOQhtOa4TWJNZlBLDySczhA5A0SMzjs1I0HdKZ5mwAem06U8RmtXji0bmWXACcBp3L7zEBuPQcpyWPW2ayKZ1vk3IASMcBXWjN4La5/exIlxqZf+fopBvQxeKT2QD7fvdI3zU2++7xgK40NHz/gGsFK+YABfMtNJrMd5GZQgpgE4RR47F50/FEPYDXU7qXmFsoBmglKAGg316KZQEaGcoBaLWZanmAVqZ8O72vtokiAC/RYEPFr6K+EoB2T48tNb/66L0eoKnRrrJEjcWLAYK1VvsKE3V2LwVwHd12lhZ+pzIA8toa7i1OFC9fBODW9dxdnmi4egGAP8M3dahMemwyLQUQ1BJ8l/KnrLUHzwQIatd+vEeq84unARyXCa7VJPXl+2cAxNM+JJqQBm0Ojwbwp+se5BqRDnXTBwK0xnXIV++QDrXqT7k2WJmnjRGFb88CqMgD94i8kyIAjEmP4pkiALy3SI9GmRoAoL/dkA75czUAXyUnx3HpfHPnWjmdz/nTW0iSTRMFAFc/GL+X/mVuf/J+1DcSAGn3hoGhiQDAYSM/MDQSAPv8hqfwmggArH35JwQaCYBBW35gaCQA8BnIDwyNBMBqKD8wNBIAWMbyA0MjATBx5QeGRgIgbXbkB4YmAgCLI91flQHQ+z+H4QkA9CP6E1cAJFP6HVsAYO7TrxgD/HkMEGcAYBYzB8DJYw4AvIXMAVBvMQdA2mUOABw2zAHEgWH5xpUEAAqfHtO8ogDCwLBsk6oCCAPDckWoLoAwMCxTt8oAwsDw9uJVtQHguPeeBCsOIAwMb2uEygMAiwaVbZiYAFB+YJinMAIAWAzp9loFYAoA4OxGUUjS+W73AJgEUCILYAEsgAWwABbAAlgAC2ABLIAFsAAWwAJYAAtgASyABbAAFsACWAALYAEsgAWwABbAAlgAC2ABLIAFsAAWwAKUANg2r+WZDSCRBbAAFsAC/GyfXlIYhIIgitYgGHhoiMmDSPyAgqKz2v/qxIkL0JoU9NlB3+6OABEgAkSACBABnAP0FMvwslGsgpeBYjW8FBQbYeZNqbKFmYlSDex0FEp2BwB8V8rkPwwViSJ5gaX5QYlkuf/DZ0i8rWwM///0m7bni5flqh5dxt8BPG/wrQSMUX8AAAAASUVORK5CYII=';
+
+/**
+ * Source: https://github.com/tabler/tabler-icons/blob/b54c86433ed5121e2590bf09f0faf746bb5aba66/icons/outline/screenshot.svg
+ *
+ * MIT License
+ *
+ * Copyright (c) 2020-2024 PaweÅ Kuna
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ *
+ * TODO: Replace with another screenshot icon when available
+ */
+export const screenshotIcon =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPAAAADwCAYAAAA+VemSAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAA8KADAAQAAAABAAAA8AAAAAAm0kfIAAAACXBIWXMAAAsTAAALEwEAmpwYAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgoZXuEHAAAh3klEQVR4Ae2dB7RlVXnHjcBQpFepMzh0HMqAHWNBNGBBjSV2Y6LG2KIus6LR2BJTLdhQ0ayFsFDJYmGwLYwFBKX3GdrAFAZmpFdRQWJ+v5m3J5fHO+/de+7d++w7831r/d49795z9v72/+xvt3PuPY96VFgoEAqEAqFAKBAKhAKhQCgQCgygwB8NsG+JXWeRySawPujbxpBsPTbS/w+wfT/8DtzvbngQajf932gCy7kh/AFKmDr9FtQtaXUn2/8LNVuqB+qmfmI9UbffgP4/BJZNc//fg+W0nqzVZqDUZPvhzJNge7ByHwL6aIXbuuf/a9k+B64GA+EEWAQ1mxXvCXAAzJ/YtrxWvl7rDWgro//7mqz3/7Sdjmnaz2MfDQvgXFgK/v9VuA1qti1wzjqhbgfBE2EPMDgvBAPVMlwGNvIbwE1gOa+Atdp6T3gXBfXkvAiOgseBwbgZeBKsYJtCqsS+Z1BrVvp7wJNny/wN+BwshWTpuPR/V6+W50/gpXAgWKbHQConm8XMhlDdHLkY9MdOcAevNVjvOdsOh6wbL4THg+de7dTNQNXsda0L9rj3gceL5bsX7KFPBgN6JZwJvdabX+/7Y7NtAUqaQXgw2BM9FnaA+WDLmk4KmwPbco44D86B88HW15PaxQnqzfNQfDBwnwdWQod+NZmjFnX7JajZJaD1lmH1O/n/pjzV6DBQt2fCvmBj3tYs453gNMty/gJs+G+G6yBsBgU8MVuCJ+JV8C2wdbQHGAXOgXrTOZX/nw42Fl2ZedswOTJwqNfrX43b+ngczIMup1UG7+Hwfcil049J+zvwEdgawmZQYEc+fx38FBzS5DoxvekaxLbiXdmBZHwiTG5cen2sbdvh9dfBkUJX9iwyzhm8vZovJa+/hW3BHn6YXp7D105zaGzL7hDGYUuvgLm3P0V+pVtYRxvayyF3+XKk/3v8fi84z+zCPkCmOco1VZo2WLeC06/Pw+SGK51LPqrXhpl3Ti5Vb4F348Oj4a3wfHAIXXpI64LYbXA5lDRHHG8A5/njZvZCG4OVelEB53vrjCvLb4J9CuRrFpbVOrI5mPcuE9vb82r5XRzr9Y9/135zdfXZ8Bm4ClLL18VQ0t7EFUh7kxInwsZwNnwcrodU9nF7tWf6NFjBS+jmvHc/+ALcBKX1SnXTgLXR+hEcBRvBOmX2sC+FM6D0SWjKz0bEBsVeJbeZh8PPZaA/qWI0+Vbz+z/D//kwyhEayU1pXon4JNwFtehmEB8J1duwK4620Iqu/QO8FuZALWZQHQAXgwtoOU0dnOeLVqL3Wp3T6P9uRZL7Q4nphw3dr8HzswXUoNsR+KHp2+mrtir94zCpraXgdajxEXgVzIGazOGzC2kbFnDK3sr5m/P9cbftKMA8GKZ+9KuB9cchdIlRUr8+ud9h8FFwYS2NRGpoXHBndObw532wEuyBasN58JUwG3KbvccvwTxr02FQf5wP/gRKzAP3JZ/FUJNu9rxJsxvYfgc4KtHWqiB+OwXyDpdU2Jpe00mwYuwJuW1rMrgFatJgGF+WUpYSvaIjpGH8LHHs7fhoXS8xkiOb/m2YIdKLyMbrnZv3n13RPVNLme6EKpG5K7hri5UqiwFoY1uz2Th/EP6mNicHCeAUEJbB4H0PHOY/Fdvv8M2FGF9zmz39Akhfa8udX870LcNCMLhym4tXniMb2pptJ5x7ATwPemOhU58HCWBPpvOAPwWD92ng5L7ESSabVubw/jwoEVTOG13tdrg17uaaxoVgmXKbq/YXgSvRtZtfTnk37AWDxE62cg3qxK548jp4Jnjd1+CtpjXCl8nmMPBGsHfMbeZxKdyRO6MC6dsrroASjXMK4HT5rUDxWmWhFi7qPQeOBlfqO7dBA9ivAO4NKSBqDl7FtSLqa4mKaG91GbiQNe6mbiV6X3UycC8BR0s1W29dfz2OGgdjZQ4fjgeDYRy4Aj/fBd7e2Ss+/2a1fyF1K+M4aDSVj+fg+2shXftkM7vNIocT4D6Yyqca3zsOXw+ATm2Qk/TnePo2GPburVIF/m8y+mcoPbdywWw3KHHpimxGbseSopUzjbJGnsEUCdrbm583dOw8xec1vuX5tcE5F5yqdWL9DqG3wbungnMAW8PaTWG9qaJ08KqLJ9TG41b/GTO7GX/VzRXhkqMWZfoxfB/u9Z8xsM3w8Ug4oktfZ+qBPYlO1u15nbxvBaVPLFkOZAbv1+C70EUQeU3TrzHa0NmjbALjYCtx8otwBjgFKG32wK7gO5x+PLhIWrt5951+/gD0vzpzOPMx8OTWOA+Z7JPBewzMha5tRxz4BFgpJ/tZ2/+e3w/DttC17YMDXwaHpbXpNJU/V+PnU8DRaXGbqQfeHI8OgcOgEwf7VMR554/h0/AtWAaK3aU5fF8Ep8J14OjFmwFqsntw5jT4DOhnDSvo9v4L4b/gJtgBamhYcGNKswd21OB1c/Usak0LUg6TDYBN4dmwIdRkLnrcCPYcVjpvBDgDFDFdT0xl4K1OTP2WT3AVr3fA28Gh1tawK3jiS5oNnUGxYgL1OgMuA+e9XZvnzJ73+gmu5dWG8A0Tr9vz6gKh+9VizoVfCI78iltTAKfe62A8svftyrweaYB6J5VzS0+mPi8Bb5q4EgwOW+zJlsow+f3S/1vZ9PvbYJBYQeeA2u4JNkYlzAVLA8JgXQBqtxhqst5zpm63wYkTr9aFvUDdZoMNoesLBpC9oOUzwEub+Xp/RCejhOlasqfi1HvgZdCF3U+mF8NXwIrm/wZtWCiQFJjHxpNhZ3CU6GKrC0td2LvI9CS4vYvMp8rzUN48AWwVS3If+Z0CrwB7qM3B+fcsCAsFehWwTmwF9rw7gPXlfXAFlKyz5nUWPBeqsTfiyQrILYRD45THJWx/AA6CjSEsFBhUgS05wLWGKyHVq946lt7L8fpXgzo77P5Nc2DTnQ87DptBH8c7jHceqOBfhJPgfggLBQZVwLp0F1iHDNB3wh4wXT3n45GZQ/kqbB+8OBlytFJTpXk5eb0JXIwICwWGUSCt6ziCc068CKaqczneO568bDCKmStoU9n+vFmqNXGh6vPgfPtBCAsFhlHAwNRctf5P+BncCyVsNpnsWyKjlEdTALuiV6o3PJO8vgERvOmsxOuoFPC6tzeEXDSqBGdIx3hab4Z9RvpxUwB7ja3E9cmbycfVO4UOCwVyKGAPbB0rYS6guSJezJoC2Avkue++cqhzGlxXrLSR0bqogJ3RuXBBgcLvRB5zC+SzJoumAH48e+yyZq98Gy40lBqq5ytFpFy7At4y6lA6t3m5qsTIdU05mgLY3lFym9/k8FbJsFAgpwLWZYMrt9nbF13LaQrg3XBkm8ylVVQXF27KnE8kHwrcjgTLC8jg3WDGTjFrCuDH4cF6kLMX9nrdDZnzIPmwUGBV77sksw7GivE0J3M+D0u+KYDTqrBBlssc0pS6QyZXGSLd8VEgd11LseI3zopZUwBfhQd+BS6X2UBcCl5sDwsFSihgfV4AOQPML+JcU6IwKY+mAF7IDjemnTK8WtDzIGcjkcHtSHKMFfBuLO/6y9lpLCb9K0pq1BTAtlQ5F5ccPv8Kiq7YlRQ28qpOATsNF01z98D+CEExawpgvxm0LKMXd5O23zjKuUiW0f1IegwVMIDtgf22Ui6zl0/rR7ny6Dtdv9vorY4G2Sixd38XeLdXWChQUgHr3DfBQBtlnTat78JzoBrzG0lfg1EX9ATS9A6ssFCgCwVeSKb2xNZrp3LD1O/e419JWmklms3uzeH1UeDdUqmQvQ6n9wZ5vYe03gphoUAXChhg9sKfAKdxg9Tdyfv2xoILV7uDVjSIvVmjyXTYCbkrxXNhWxjGOYctX4fvwK0QFgp0oYALp9Y/v6zjKLPtvfgpFgzeY6Day6IW9G3Q2xNPbo1m+t8FBAtpQxAWCtSggL86cyy4Kj1T/W363OD9C8h9kwhZDGf2vnvB6+F0mKpAvUOK9Lkrct+HN8AcmK7H5+OwUKCYArPIaTYcDB+GqTqoqeq0dXsFfAmOgM2hM+u35XAoLddNvBqIj4HNYM7EtkMKC7cUvIa8ErzudjY4vEg3baT9eCssFOhEAeugPe+yCZZP/P9qXh0t7gRzwP00p3/ucyMsAW9COgeuBYO8M0sO9uOA+xqg2tHg3MFfHzgA9gZbtGXgsMLWTK6CsFCgZgVSvfb778+E34KjzQPB9xxFWo9lIXiPhJdXw0KBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgZEqMMiPx48040gsFFjbFWgbXD5WZcsJtuJ1C/BxpD6W4g7wMaI+fsVnyNwPYaFA7QoYC6kuW5+3Buv5Q3A3WK99SsPv4QaowgYJYAPUoLRwT4CDYP4Ej+NVuxPOBR8/YeFPgF9CWChQqwLWaev27vAUmAc+LuiJYF3XFsH5cCvYOR0DBvRYmYX5M/gB+OAyWyUfWObzVn1mktha/QYspO+fBIdArw3SaPQeF9uhwKgU6K2Ddj4fggvBOm3dtQ5bl1O9ti5b133fB5x9EFJws1mn9RbSHvdzcA04JE4Fm+nVJxr6FLevwkthY9B60179TvwNBcookOqeI8RXwfdgOfQG7Ez1ejH7/wQ+Cr0dVEqbt+uwjXBDB48HW6CZCtb7+eRnqzqUfjlsCmGhQJcK2Hu+Bi6A3jo76LYxcRw45F4fqrO5eHQiOISwcJODctACG8QvhrBQoEsFHA0OG7wpFhxWfx327LJATXk/jQ8GDdKZ9rdB2K0pw3g/FCiggItQM9XTQT53Zfot4JWZoubqW5P5lPIXNn04xPtP5tiXDXF8HBoKDKpA79zU+ueQd5TmSvYr4cBRJjpMWg4H/hGWwiAtUb/7/g/p2kBM14DwcVgoMDIFXHs5HE4BLwH1W1f73c9e2NVpg7mYNQWQQ2fnqrMzebI36T4JXAkMCwVKKLAVmbwAjgK3DcxRmoF7MOw3ykRnSqspgB/gQFfYctksEvZGkHRZKVc+kW4okBRw0ckFp1Sve4fVaZ9hXx1V7jNsIoMc3xTA9pA6k8s2IWHnIRHAuRSOdCcr4GjPOaqXRnPZLiRcdDW6KYDtHXUmlzkfiSF0LnUj3akU2Hyiztl55DKvrngrZjFrCuAdM3vg8MU8HEqHhQIlFNiQTLaBHENn/U9z6h38p5Q1BbA3buS2dCE8dz6RfiigAinAcqmRGgZXo4tZUwAXcyAyCgVCgfYKRAC31y6ODAU6VyACuPNTEA6EAu0ViABur10cGQp0rkAEcOenIBwIBdorEAHcXrs4MhToXIEI4M5PQTgQCrRXIAK4vXZxZCjQuQIRwJ2fgnAgFGivQARwe+3iyFCgcwUigDs/BeFAKNBegQjg9trFkaFA5wpEAHd+CsKBUKC9AhHA7bWLI0OBzhWIAO78FIQDoUB7BSKA22sXR4YCnSsQAdz5KQgHQoH2CkQAt9cujgwFOlcgArjzUxAOhALtFYgAbq9dHBkKdK5ABHDnpyAcCAXaKxAB3F67ODIU6FyBCODOT0E4EAq0VyACuL12cWQo0LkCTQFc4hGJTXl3Lko4EAoMoUDRet2UmU9xy23m4dMZwkKBEgpY1x4qkNFvC+SxJoumAF7AHnet2Wv0GwbvRXD/6JOOFEOBKRW4l3cvht9N+elo3vTB4VeOJqn+UmkK4Ks4/Kb+kmi1130cdQFEALeSLw5qoYDPBb4Uco4ul5L+QihmTQFsD7wigxfpAVPrk7YtYokhTYZiRJJjqID1zVFfzgf3+WCz9ADxIhI1BbCtyKIMHvgENwu5GJZDTjFJPiwUWKOAo74L4eY174x2w57den3raJNtn9prJxyy1xwlDp1f1d6tODIUaK2AI7+vwu0wyjptWifAIVCNzcWTz8CoC3pcNSUMR9Y1BRwBPgPOAeu1K9PD1O/e448irersmXh0LqRC9jqc3hvk1YWxN0BYKNCVAhuQ8fvBNZ5B6u7kfVMs+HoWzAEtPeh79X+Z/643Q/q38bnDjV1gJ2iaM/PRjKZgx8L3IOclqhkdiR3WaQUMuJVgQO4Lj4E2ltZzfsHBjlS9fJTzElUbH9cc80q2fg4PgBeqJ7dGTf+7yiw3wIdge9CKtlKrs4y/ocDDFNiZ/z4By8EFKBdUm+rx5PddiPW9n8FLoHrbBA83hWfBt2FygZr+v5t9T4TnwlYQgYsIYVUo4OhzC9gG3gxeYmqqx5PfX8S+H4UDwYWxzqzfzNMNF2fgqT2whTeotwaHIZuDZkGvgutgKZwPCrMMbOXCQoFaFHBkaAejnQR2Lq8Gr+PuDtbrZHeyYdDK1WC9dsjstNAheWc2SI/ovgao9gww+A3gvWAOuDjg3OJaWAJLJ+AlLBSoVoFUr53eHQrOY2fD3rADOG20Pl8/wWJeDeiwUCAUqESBQTqySlwON0KBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBKRWI39ydUpZ4MxQYXoG2weWjVB4LO8JO4C/Y+7iVO+BG8CnlPqnBx1DcC2GhwDgoYH22LsvO4PO8fJDZr+Am8LErPqlhIVRhgwSwAboZWLAnwrwJDuDVx1JoPpbiYrgGfGzj8fAD+AOEhQI1KrAhTtkh+SykJ0+87sfrfEjPDvORQZfA7eDzlD4FS2GsbBe8fQdcCLZCBqUPdvK1F99L7/+Q7SMgLBSoUQED1ED9HCyDVKdT/Z1cr/0/BfActqu23t75OXh6MvjA799Cb8Gm23b4vAR+DG8Ge3CtN+3V78TfUKCMAqnuOTy2Q7oIfFiZQ+Xp6nL6zOC+Ba6Dr8MzIFlKO/3f+eumeHA4fBccHqdC9PM6uRVz3vBW2BbCQoEuFXD95p3gVK+futy0jzFxCvwxzILqzEeHpjmshZgclE0Fa3p/AWm8vrpShkPrmgKvo8DXQlM97ef9FAv23KeCa0HV2XPxqJ/CDLLPaaS5f3UlDYfWJQW+RmEHqbP97Pt+0nT1uqg9eprc7H1fMs3nbT86mANf3vbgOC4UaKFA79z0SI53lXnU9goStG5XYT6p/Fjwem4/rc+g+5xFuo+DtEzPZlgokFWBLUndIPsp3AeD1tl+9v846W4MxaypB7aFOgxyLTjNIe0nwiYQFgqUUMAF2SeB13q9R8GAHLXNI8EcvXujn00B7JDDa725zIvnXn+LAM6lcKQ7lQIG7W8mPugdVk+1b5v3duEgbwgpZk0BbCviHVe5zMC1tSo63MhVmEh3LBTYDC/tNHLWOWNmz5JqNAXwgTiRc0XNIYxDaF/DQoESChjA1rmcAbwj6T++RGFSHk0BnOa+OeYJKe+t00a8hgIFFNiAPEp0GN7hVcyaAviOCQ9yzBNS4bwQ7k3kYaFACQVydka9/ntjRzFrCuAFeHBbZi9sHLx7JXrizEJH8muvAk0BXKrEfjHiwVKZRT6hwNqmQFMAe4OF3//NbX7LaW7uTCL9UGBtVaApgB0+5/4lDYfQR4MX1sNCgVCghQJNAXw1aa1okd6gh3h3zFPA+67DQoFQYEAFmgJ4FumUuk/5j8nr+QP6HbuHAqEACjQF8PV8dnMhheaQz4vAe6+T5bx8lfKI11Bg7BVo6mUvoWTLC5TOa3MG66HwXrBBORdy3odN8mGhwNqhQFMP7OWdGwsU0eA1iJ0L+93j90AsaiFCWCjQjwJNPbDHuoh1E+T8UoP59A6XX8z/BrR3aZ0NYaFAKDCNAtNd6/UHu9IN4NMkMfKPdiLFZ8Hh4Ahhf/De7LtAn5qstyFo2ifef6QC64pujvLe/sjij/wdr+CcNPJUGxKc6eT50zcnNxyb4+00J05pn8+G790JS8BRgY3OrbAMbgFvUr8Mfg2Wx/1rte1wzAbKUc2u4De+HG3kNnURnzCgbt7r7v8XwUMT2zXrpmaJ3djeZsJvXvoyp4R7wZv72nu4nVwAPg6sl/2a58Lz4Ij3Brgf/N7y5aD5+ZTnxw+mM+ejZ8F0Q+3pjs/1mT3xpaBYfrdYwU4HRajNPJF+Q2VPUM99wFHFPLBXKGn3kJm6ub7huT8GUiPJZlXmjz54n7xaqdveoHZ+1XWQ4GD3YmaQzRRT0zmzkg8NWs+TndOnYAm0NiuZAWxroHO18kN8ex5o9tDDiLgqkRH90Y/94BNgY1Obfg71ngRa04Lm6k/L/p1Fdl6Z+DzYK9WmWwl/7qbcBvBc0FqdH1vAN8HtoNMO90o4P2ge9+GXrdfP4S3wGNC6COSUp6OWV8OZ4G2pD8Kg5cq9v5VkBXwfXgbJUhnS/yVeU55bkNk7wJGCuvn1vNw61Ji+o0njztHSN+AZ0Mr8nZ+zwQRrLOhknxbi51vB+WZX5hzN4HV4WmujN1k3z/HLocvvaO9I/u+Ea2Cyf+vy/87hT4Gnw8OmDw43ZzLH41bIvWDLmXbu8HNPsK24gbs72CNfAV2YP93yMXAYqE/JNzars+Sbi0M21teBw/0u7AVk+ndgXQtbrYDnx9HcHmAcOkd2EXeV9RPAjr2XwzxwPlerGSjJtp/YsDdeU9j0YYHXo8nDqUeyXt/Se7W89vq2K07ZYF8Fd3XgoCOnIzvIt+Ys0/kxVvcFG9fF8Gvo6zu/tgDOlRwKevnDlnocbDOcdDneeXFJeyqZvQZcMR1H2wqnHb1cXNj5o8jPIfy41K/C8qzKzlhMvfAS3+mnB151JH+Meu1psCGklsH3ajTncjY6Z4Ktlds5bRaJHw7vgiPA/8fRrCD2vjZ8zr1ym+fJEcu74QnwsDke/4f9vwLGnNfDr4UL4fcOj/sxD/Ta60/htH4OqGSf3fHDSrFRAX9sDJ1iOO/1+q6t5biac9CDod/6MUw5/ZnXA2E+uD3OuuF+ETuAXFaN8Po9QUlUr8mdCMuKuDl8JlYIK4YjhhLmYsMDExnZ6I2r2QsbVP3Wj2HKqU42fqm3H2fdhtFhkGNns/PeHjDoCVJku+4vwzgEsT2hi28lAtjgtdey8o+7bUsBvANq0PrRptybcJCNbLp23yaNde2Y3SjwXAvd5gTdw3FfgZ9D7nklWQxlLmSVGkIbwIdAzZfa+hXThaxSQ2gD2MtuEcD9np1HPcrr5a5Itwpgh9N3w6fhePBOmRotDfu9pOQQLbc59LPn0lLeq/8br7/Jd8tSYjhrw7fFeEnUqbfp/KzqKNr0wHrvib0UPgOnwr1Qm6XKdzOOPVTAOYVN15xT3gWyHXkWyXfLkirLyDPpSdAOwFXvsP4USOfnTndvG8DpxF5BGu8Bh9M1ngQblgsgLZCwmc2siK4PrBI2Wy5lEr6DbC6GElMkL/GdD76G9afACnbzZpvWAdybzUr+eQv8Gyzp/aCCbQPYilgigO3lF8DaEMC3UI7LoEQA/4Z8LgdvugnrTwHjzPvFRxLAnmRbhK+Bi1uLoWtLIwRXn0tUQstrD+y0wvWBcbWk2ywKUEo3A9hG9nfjKloHfrums+r8tB1CT+Wzc6bj4auwFByGpQrBZlFznpCGZpewXaJyGMBWxEUwrqZuNkDnwUIoEcTpPC0jv7DpFTCeHB2dC4vddZSrs578+8DbvByy3gZeh90a/Ky0OS//VzgdHiyQueI6DHTV27tkLPc42ndw+j/ASlKiAbaRcNqxO3htc3MIm1oBtbKD/DxcM/Uuw79rr74xeHngFXAOWBESOpG2c77+E/l0YVZCRc5Ztpxpv7EL0cjTG26+BznLtjakPb/3/IyyB07pKpLDSQPVWy9/AWeBvaCtrPOrHGa+qad3+PcNyNZKTVMAexPLvgfsPLFfr28Tb1Xz0uvbL/HqRPC8lTaHhtaNvWG70plXnF86P8bPqXAyONJdZTkCOKVtMDmU/hV4uekGSCfmHrZHPcRMwWten4WfgvOrLszK6BRilwmSb134MlOe+uYK+tnwabgISqzak80jzIbDc+YoZttHfLpuvuH5caHvNDgGloId5CrLGcApj/S6gg0XthbA1bAD2FPpnBVoQ2hrHm+r5ILVsfBN8Lp0F4FjngbAVWBD5XRCfH+YMnL4yM1WXd0cIX0BbOH1vSvdDF5HTdYJb4O1fkquURtJV22eCy+FGrxfBEezBm8X54dsV2ecMvfE/DV8BAw4hwptWcKx/w7Oo5yD12KprE/GoW9D2/LlOu5KfPp7cLhfkyXdjsSpH0Gu8tecrsF7DrwZdoIpLQk15YcF3tyGPDYAeyad3A8OAldy7R0UeH9wZdIV7R1As3CXghXwanDF1JbbXvcBqM3U+SnwTrBcs+FgsJcpaV4iuhwcBamdui0FdVszLGO7FrORfz68Eeyd94T5YJ2p0ayvw8SUo9SF4Pmxfl8ATkE9b45WH2HDZPaIxEbwhhV6VzBYdVhBdgSHoCnIPanOL5fDSrCAN0OtpsaWw0p3EDjct+GynDZUU54Y3h+16Yc63QRWFLVzSlOrJd1svG3YbfjUS93UTx37NYfk+4Ajvty2iAy+BJ7vfuPL/SyP56UXpzdhFSjQ74mswNWqXBilbvtSMhvS3HyvpII1zRdLlrt0XlaasMEVGKVuWw2efasjZrU6quVBEcAthYvDQoEaFIgAruEshA+hQEsFIoBbCheHhQI1KBABXMNZCB9CgZYKRAC3FC4OCwVqUCACuIazED6EAi0ViABuKVwcFgrUoEAEcA1nIXwIBVoqEAHcUrg4LBSoQYEI4BrOQvgQCrRUIAK4pXBx2NgpMMr7qqcqfLrtc/2pPsz1XgRwLmUj3doU8NtMOb/dkxqIot/wigCurZqFP7kU8NdRzof7c2VAun4V0O/yFrMI4GJSR0YdK+APAvhjBv4YRC67gYT9gYliFgFcTOrIqGMFHD77m2mD/BDAoC77oxNFLQK4qNyRWYcK+ONw58HSTD74yzBnwpWZ0o9kQ4FQAAU+CTfCqH+Z41OkOQfCQoFQIJMCrhTPg9Nh1AHsjxYWN38gLiwUWJcUuIXCOlfdE7YbouA2ADYI/pqnv6d9CvjrkUUtArio3JFZJQo4hHZVei5s2+NTCsqetxo3DV5/7fI0+CxcB8V/mjcCGNXD1ikFDDyD159/dWV6IzBw/TG6fleRvRR1O3wX/AnZX4DBa9phoUAoUFiB55FfegKEgeilpvS75Aa32+k9g/9s+EtIDxpgsxtbv5tsI9dQoCoFDF574jvBAPVH4A8Ff5xdWwneBHLZBD4xwbm0+3Zq0eV3Kn9kXpECW+CLc2J74O3AR/1sCd5DbbD6VAuv9frq0DssFAgFKlEgOrJKTkS4EQqsUwr8H4DFXUwZwWxSAAAAAElFTkSuQmCC';
diff --git a/packages/core/src/js/feedback/integration.ts b/packages/core/src/js/feedback/integration.ts
index 96280cf967..d450422aa3 100644
--- a/packages/core/src/js/feedback/integration.ts
+++ b/packages/core/src/js/feedback/integration.ts
@@ -1,27 +1,102 @@
import { type Integration, getClient } from '@sentry/core';
-import type { FeedbackWidgetProps } from './FeedbackWidget.types';
+import type { FeedbackWidgetTheme } from './FeedbackWidget.theme';
+import type { FeedbackButtonProps, FeedbackWidgetProps, ScreenshotButtonProps } from './FeedbackWidget.types';
export const MOBILE_FEEDBACK_INTEGRATION_NAME = 'MobileFeedback';
type FeedbackIntegration = Integration & {
options: Partial;
+ buttonOptions: Partial;
+ screenshotButtonOptions: Partial;
+ colorScheme?: 'system' | 'light' | 'dark';
+ themeLight: Partial;
+ themeDark: Partial;
};
-export const feedbackIntegration = (initOptions: FeedbackWidgetProps = {}): FeedbackIntegration => {
+export const feedbackIntegration = (
+ initOptions: FeedbackWidgetProps & {
+ buttonOptions?: FeedbackButtonProps;
+ screenshotButtonOptions?: ScreenshotButtonProps;
+ colorScheme?: 'system' | 'light' | 'dark';
+ themeLight?: Partial;
+ themeDark?: Partial;
+ } = {},
+): FeedbackIntegration => {
+ const {
+ buttonOptions,
+ screenshotButtonOptions,
+ colorScheme,
+ themeLight: lightTheme,
+ themeDark: darkTheme,
+ ...widgetOptions
+ } = initOptions;
+
return {
name: MOBILE_FEEDBACK_INTEGRATION_NAME,
- options: initOptions,
+ options: widgetOptions,
+ buttonOptions: buttonOptions || {},
+ screenshotButtonOptions: screenshotButtonOptions || {},
+ colorScheme: colorScheme || 'system',
+ themeLight: lightTheme || {},
+ themeDark: darkTheme || {},
};
};
+const _getClientIntegration = (): FeedbackIntegration => {
+ return getClient()?.getIntegrationByName>(MOBILE_FEEDBACK_INTEGRATION_NAME);
+};
+
export const getFeedbackOptions = (): Partial => {
- const integration = getClient()?.getIntegrationByName>(
- MOBILE_FEEDBACK_INTEGRATION_NAME,
- );
+ const integration = _getClientIntegration();
if (!integration) {
return {};
}
return integration.options;
};
+
+export const getFeedbackButtonOptions = (): Partial => {
+ const integration = _getClientIntegration();
+ if (!integration) {
+ return {};
+ }
+
+ return integration.buttonOptions;
+};
+
+export const getScreenshotButtonOptions = (): Partial => {
+ const integration = _getClientIntegration();
+ if (!integration) {
+ return {};
+ }
+
+ return integration.screenshotButtonOptions;
+};
+
+export const getColorScheme = (): 'system' | 'light' | 'dark' => {
+ const integration = _getClientIntegration();
+ if (!integration) {
+ return 'system';
+ }
+
+ return integration.colorScheme;
+};
+
+export const getFeedbackLightTheme = (): Partial => {
+ const integration = _getClientIntegration();
+ if (!integration) {
+ return {};
+ }
+
+ return integration.themeLight;
+};
+
+export const getFeedbackDarkTheme = (): Partial => {
+ const integration = _getClientIntegration();
+ if (!integration) {
+ return {};
+ }
+
+ return integration.themeDark;
+};
diff --git a/packages/core/src/js/feedback/lazy.ts b/packages/core/src/js/feedback/lazy.ts
index ba02365c2f..c3d2b2727d 100644
--- a/packages/core/src/js/feedback/lazy.ts
+++ b/packages/core/src/js/feedback/lazy.ts
@@ -25,3 +25,29 @@ export function lazyLoadAutoInjectFeedbackIntegration(): void {
getClient()?.addIntegration({ name: AUTO_INJECT_FEEDBACK_INTEGRATION_NAME });
}
}
+
+export const AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME = 'AutoInjectMobileFeedbackButton';
+
+/**
+ * Lazy loads the auto inject feedback button integration if it is not already loaded.
+ */
+export function lazyLoadAutoInjectFeedbackButtonIntegration(): void {
+ const integration = getClient()?.getIntegrationByName(AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME);
+ if (!integration) {
+ // Lazy load the integration to track usage
+ getClient()?.addIntegration({ name: AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME });
+ }
+}
+
+export const AUTO_INJECT_SCREENSHOT_BUTTON_INTEGRATION_NAME = 'AutoInjectMobileScreenshotButton';
+
+/**
+ * Lazy loads the auto inject screenshot button integration if it is not already loaded.
+ */
+export function lazyLoadAutoInjectScreenshotButtonIntegration(): void {
+ const integration = getClient()?.getIntegrationByName(AUTO_INJECT_SCREENSHOT_BUTTON_INTEGRATION_NAME);
+ if (!integration) {
+ // Lazy load the integration to track usage
+ getClient()?.addIntegration({ name: AUTO_INJECT_SCREENSHOT_BUTTON_INTEGRATION_NAME });
+ }
+}
diff --git a/packages/core/src/js/feedback/utils.ts b/packages/core/src/js/feedback/utils.ts
index 9c2826981d..6644bd7468 100644
--- a/packages/core/src/js/feedback/utils.ts
+++ b/packages/core/src/js/feedback/utils.ts
@@ -18,6 +18,15 @@ export function isModalSupported(): boolean {
return !(isFabricEnabled() && major === 0 && minor < 71);
}
+/**
+ * The native driver supports color animations since React Native 0.69.
+ * ref: https://github.com/facebook/react-native/commit/201f355479cafbcece3d9eb40a52bae003da3e5c
+ */
+export function isNativeDriverSupportedForColorAnimations(): boolean {
+ const { major, minor } = ReactNativeLibraries.ReactNativeVersion?.version || {};
+ return major > 0 || minor >= 69;
+}
+
export const isValidEmail = (email: string): boolean => {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return emailRegex.test(email);
diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts
index 4508684156..1a163a2b06 100644
--- a/packages/core/src/js/index.ts
+++ b/packages/core/src/js/index.ts
@@ -12,6 +12,8 @@ export type {
Thread,
User,
UserFeedback,
+ ErrorEvent,
+ TransactionEvent,
} from '@sentry/core';
export {
@@ -79,11 +81,16 @@ export {
startIdleNavigationSpan,
startIdleSpan,
getDefaultIdleNavigationSpanOptions,
+ createTimeToFullDisplay,
+ createTimeToInitialDisplay,
} from './tracing';
export type { TimeToDisplayProps } from './tracing';
export { Mask, Unmask } from './replay/CustomMask';
+export { FeedbackButton } from './feedback/FeedbackButton';
export { FeedbackWidget } from './feedback/FeedbackWidget';
-export { showFeedbackWidget } from './feedback/FeedbackWidgetManager';
+export { showFeedbackWidget, showFeedbackButton, hideFeedbackButton } from './feedback/FeedbackWidgetManager';
+
+export { getDataFromUri } from './wrapper';
diff --git a/packages/core/src/js/integrations/appRegistry.ts b/packages/core/src/js/integrations/appRegistry.ts
new file mode 100644
index 0000000000..2467d73876
--- /dev/null
+++ b/packages/core/src/js/integrations/appRegistry.ts
@@ -0,0 +1,56 @@
+import type { Client, Integration } from '@sentry/core';
+import { getClient, logger } from '@sentry/core';
+
+import { isWeb } from '../utils/environment';
+import { fillTyped } from '../utils/fill';
+import { ReactNativeLibraries } from '../utils/rnlibraries';
+
+export const INTEGRATION_NAME = 'AppRegistry';
+
+export const appRegistryIntegration = (): Integration & {
+ onRunApplication: (callback: () => void) => void;
+} => {
+ const callbacks: (() => void)[] = [];
+
+ return {
+ name: INTEGRATION_NAME,
+ setupOnce: () => {
+ if (isWeb()) {
+ return;
+ }
+
+ patchAppRegistryRunApplication(callbacks);
+ },
+ onRunApplication: (callback: () => void) => {
+ if (callbacks.includes(callback)) {
+ logger.debug('[AppRegistryIntegration] Callback already registered.');
+ return;
+ }
+ callbacks.push(callback);
+ },
+ };
+};
+
+export const patchAppRegistryRunApplication = (callbacks: (() => void)[]): void => {
+ const { AppRegistry } = ReactNativeLibraries;
+ if (!AppRegistry) {
+ return;
+ }
+
+ fillTyped(AppRegistry, 'runApplication', originalRunApplication => {
+ return (...args) => {
+ callbacks.forEach(callback => callback());
+ return originalRunApplication(...args);
+ };
+ });
+};
+
+export const getAppRegistryIntegration = (
+ client: Client | undefined = getClient(),
+): ReturnType | undefined => {
+ if (!client) {
+ return undefined;
+ }
+
+ return client.getIntegrationByName(INTEGRATION_NAME);
+};
diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts
index f41f68c058..c1be11f359 100644
--- a/packages/core/src/js/integrations/default.ts
+++ b/packages/core/src/js/integrations/default.ts
@@ -3,8 +3,9 @@ import type { Integration } from '@sentry/core';
import type { ReactNativeClientOptions } from '../options';
import { reactNativeTracingIntegration } from '../tracing';
-import { isExpoGo, notWeb } from '../utils/environment';
+import { notWeb } from '../utils/environment';
import {
+ appRegistryIntegration,
appStartIntegration,
breadcrumbsIntegration,
browserApiErrorsIntegration,
@@ -32,6 +33,7 @@ import {
sdkInfoIntegration,
spotlightIntegration,
stallTrackingIntegration,
+ timeToDisplayIntegration,
userInteractionIntegration,
viewHierarchyIntegration,
} from './exports';
@@ -111,15 +113,17 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ
integrations.push(userInteractionIntegration());
}
if (hasTracingEnabled && options.enableAutoPerformanceTracing) {
+ integrations.push(appRegistryIntegration());
integrations.push(reactNativeTracingIntegration());
}
+ if (hasTracingEnabled) {
+ integrations.push(timeToDisplayIntegration());
+ }
if (options.enableCaptureFailedRequests) {
integrations.push(httpClientIntegration());
}
- if (isExpoGo()) {
- integrations.push(expoContextIntegration());
- }
+ integrations.push(expoContextIntegration());
if (options.spotlight) {
const sidecarUrl = typeof options.spotlight === 'string' ? options.spotlight : undefined;
diff --git a/packages/core/src/js/integrations/expocontext.ts b/packages/core/src/js/integrations/expocontext.ts
index 8e1db1a3ab..b805daf118 100644
--- a/packages/core/src/js/integrations/expocontext.ts
+++ b/packages/core/src/js/integrations/expocontext.ts
@@ -1,21 +1,122 @@
-import type { DeviceContext, Event, Integration, OsContext } from '@sentry/core';
+import { type DeviceContext, type Event, type Integration, type OsContext, logger } from '@sentry/core';
-import { getExpoDevice } from '../utils/expomodules';
+import type { ReactNativeClient } from '../client';
+import { isExpo, isExpoGo } from '../utils/environment';
+import { getExpoDevice, getExpoUpdates } from '../utils/expomodules';
+import { NATIVE } from '../wrapper';
const INTEGRATION_NAME = 'ExpoContext';
+export const OTA_UPDATES_CONTEXT_KEY = 'ota_updates';
+
/** Load device context from expo modules. */
export const expoContextIntegration = (): Integration => {
+ let _expoUpdatesContextCached: ExpoUpdatesContext | undefined;
+
+ function setup(client: ReactNativeClient): void {
+ client.on('afterInit', () => {
+ if (!client.getOptions().enableNative) {
+ return;
+ }
+
+ setExpoUpdatesNativeContext();
+ });
+ }
+
+ function setExpoUpdatesNativeContext(): void {
+ if (!isExpo() || isExpoGo()) {
+ return;
+ }
+
+ const expoUpdates = getExpoUpdatesContextCached();
+
+ try {
+ // Ensures native errors and crashes have the same context as JS errors
+ NATIVE.setContext(OTA_UPDATES_CONTEXT_KEY, expoUpdates);
+ } catch (error) {
+ logger.error('Error setting Expo updates context:', error);
+ }
+ }
+
+ function processEvent(event: Event): Event {
+ if (!isExpo()) {
+ return event;
+ }
+
+ addExpoGoContext(event);
+ addExpoUpdatesContext(event);
+ return event;
+ }
+
+ function addExpoUpdatesContext(event: Event): void {
+ event.contexts = event.contexts || {};
+ event.contexts[OTA_UPDATES_CONTEXT_KEY] = {
+ ...getExpoUpdatesContextCached(),
+ };
+ }
+
+ function getExpoUpdatesContextCached(): ExpoUpdatesContext {
+ if (_expoUpdatesContextCached) {
+ return _expoUpdatesContextCached;
+ }
+
+ return (_expoUpdatesContextCached = getExpoUpdatesContext());
+ }
+
return {
name: INTEGRATION_NAME,
- setupOnce: () => {
- // noop
- },
+ setup,
processEvent,
};
};
-function processEvent(event: Event): Event {
+/**
+ * @internal Exposed for testing purposes
+ */
+export function getExpoUpdatesContext(): ExpoUpdatesContext {
+ const expoUpdates = getExpoUpdates();
+ if (!expoUpdates) {
+ return {
+ is_enabled: false,
+ };
+ }
+
+ const updatesContext: ExpoUpdatesContext = {
+ is_enabled: !!expoUpdates.isEnabled,
+ is_embedded_launch: !!expoUpdates.isEmbeddedLaunch,
+ is_emergency_launch: !!expoUpdates.isEmergencyLaunch,
+ is_using_embedded_assets: !!expoUpdates.isUsingEmbeddedAssets,
+ };
+
+ if (typeof expoUpdates.updateId === 'string' && expoUpdates.updateId) {
+ updatesContext.update_id = expoUpdates.updateId.toLowerCase();
+ }
+ if (typeof expoUpdates.channel === 'string' && expoUpdates.channel) {
+ updatesContext.channel = expoUpdates.channel.toLowerCase();
+ }
+ if (typeof expoUpdates.runtimeVersion === 'string' && expoUpdates.runtimeVersion) {
+ updatesContext.runtime_version = expoUpdates.runtimeVersion.toLowerCase();
+ }
+ if (typeof expoUpdates.checkAutomatically === 'string' && expoUpdates.checkAutomatically) {
+ updatesContext.check_automatically = expoUpdates.checkAutomatically.toLowerCase();
+ }
+ if (typeof expoUpdates.emergencyLaunchReason === 'string' && expoUpdates.emergencyLaunchReason) {
+ updatesContext.emergency_launch_reason = expoUpdates.emergencyLaunchReason;
+ }
+ if (typeof expoUpdates.launchDuration === 'number') {
+ updatesContext.launch_duration = expoUpdates.launchDuration;
+ }
+ if (expoUpdates.createdAt instanceof Date) {
+ updatesContext.created_at = expoUpdates.createdAt.toISOString();
+ }
+ return updatesContext;
+}
+
+function addExpoGoContext(event: Event): void {
+ if (!isExpoGo()) {
+ return;
+ }
+
const expoDeviceContext = getExpoDeviceContext();
if (expoDeviceContext) {
event.contexts = event.contexts || {};
@@ -27,8 +128,6 @@ function processEvent(event: Event): Event {
event.contexts = event.contexts || {};
event.contexts.os = { ...expoOsContext, ...event.contexts.os };
}
-
- return event;
}
/**
@@ -66,3 +165,17 @@ function getExpoOsContext(): OsContext | undefined {
name: expoDevice.osName,
};
}
+
+type ExpoUpdatesContext = Partial<{
+ is_enabled: boolean;
+ is_embedded_launch: boolean;
+ is_emergency_launch: boolean;
+ is_using_embedded_assets: boolean;
+ update_id: string;
+ channel: string;
+ runtime_version: string;
+ check_automatically: string;
+ emergency_launch_reason: string;
+ launch_duration: number;
+ created_at: string;
+}>;
diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts
index f5fabb397e..e87a88c615 100644
--- a/packages/core/src/js/integrations/exports.ts
+++ b/packages/core/src/js/integrations/exports.ts
@@ -20,6 +20,8 @@ export { nativeFramesIntegration, createNativeFramesIntegrations } from '../trac
export { stallTrackingIntegration } from '../tracing/integrations/stalltracking';
export { userInteractionIntegration } from '../tracing/integrations/userInteraction';
export { createReactNativeRewriteFrames } from './rewriteframes';
+export { appRegistryIntegration } from './appRegistry';
+export { timeToDisplayIntegration } from '../tracing/integrations/timeToDisplayIntegration';
export {
breadcrumbsIntegration,
@@ -32,4 +34,5 @@ export {
inboundFiltersIntegration,
linkedErrorsIntegration as browserLinkedErrorsIntegration,
rewriteFramesIntegration,
+ extraErrorDataIntegration,
} from '@sentry/react';
diff --git a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts
index 4025dc13d5..df56da47cb 100644
--- a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts
+++ b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts
@@ -1,6 +1,14 @@
import type { EventHint, Integration, SeverityLevel } from '@sentry/core';
-import { addExceptionMechanism, captureException, getClient, getCurrentScope, logger } from '@sentry/core';
-
+import {
+ addExceptionMechanism,
+ addGlobalUnhandledRejectionInstrumentationHandler,
+ captureException,
+ getClient,
+ getCurrentScope,
+ logger,
+} from '@sentry/core';
+
+import { isHermesEnabled, isWeb } from '../utils/environment';
import { createSyntheticError, isErrorLike } from '../utils/error';
import { RN_GLOBAL_OBJ } from '../utils/worldwide';
import { checkPromiseAndWarn, polyfillPromise, requireRejectionTracking } from './reactnativeerrorhandlersutils';
@@ -44,49 +52,83 @@ function setup(options: ReactNativeErrorHandlersOptions): void {
* Setup unhandled promise rejection tracking
*/
function setupUnhandledRejectionsTracking(patchGlobalPromise: boolean): void {
- if (patchGlobalPromise) {
- polyfillPromise();
- }
+ try {
+ if (
+ isHermesEnabled() &&
+ RN_GLOBAL_OBJ.HermesInternal?.enablePromiseRejectionTracker &&
+ RN_GLOBAL_OBJ?.HermesInternal?.hasPromise?.()
+ ) {
+ logger.log('Using Hermes native promise rejection tracking');
+
+ RN_GLOBAL_OBJ.HermesInternal.enablePromiseRejectionTracker({
+ allRejections: true,
+ onUnhandled: promiseRejectionTrackingOptions.onUnhandled,
+ onHandled: promiseRejectionTrackingOptions.onHandled,
+ });
- attachUnhandledRejectionHandler();
- checkPromiseAndWarn();
+ logger.log('Unhandled promise rejections will be caught by Sentry.');
+ } else if (isWeb()) {
+ logger.log('Using Browser JS promise rejection tracking for React Native Web');
+
+ // Use Sentry's built-in global unhandled rejection handler
+ addGlobalUnhandledRejectionInstrumentationHandler((error: unknown) => {
+ captureException(error, {
+ originalException: error,
+ syntheticException: isErrorLike(error) ? undefined : createSyntheticError(),
+ mechanism: { handled: false, type: 'onunhandledrejection' },
+ });
+ });
+ } else if (patchGlobalPromise) {
+ // For JSC and other environments, use the existing approach
+ polyfillPromise();
+ attachUnhandledRejectionHandler();
+ checkPromiseAndWarn();
+ } else {
+ // For JSC and other environments, patching was disabled by user configuration
+ logger.log('Unhandled promise rejections will not be caught by Sentry.');
+ }
+ } catch (e) {
+ logger.warn(
+ 'Failed to set up promise rejection tracking. ' +
+ 'Unhandled promise rejections will not be caught by Sentry.' +
+ 'See https://docs.sentry.io/platforms/react-native/troubleshooting/ for more details.',
+ );
+ }
}
-function attachUnhandledRejectionHandler(): void {
- const tracking = requireRejectionTracking();
+const promiseRejectionTrackingOptions: PromiseRejectionTrackingOptions = {
+ onUnhandled: (id, error: unknown, rejection = {}) => {
+ if (__DEV__) {
+ logger.warn(`Possible Unhandled Promise Rejection (id: ${id}):\n${rejection}`);
+ }
- const promiseRejectionTrackingOptions: PromiseRejectionTrackingOptions = {
- onUnhandled: (id, rejection = {}) => {
- // eslint-disable-next-line no-console
- console.warn(`Possible Unhandled Promise Rejection (id: ${id}):\n${rejection}`);
- },
- onHandled: id => {
- // eslint-disable-next-line no-console
- console.warn(
+ // Marking the rejection as handled to avoid breaking crash rate calculations.
+ // See: https://github.com/getsentry/sentry-react-native/issues/4141
+ captureException(error, {
+ data: { id },
+ originalException: error,
+ syntheticException: isErrorLike(error) ? undefined : createSyntheticError(),
+ mechanism: { handled: true, type: 'onunhandledrejection' },
+ });
+ },
+ onHandled: id => {
+ if (__DEV__) {
+ logger.warn(
`Promise Rejection Handled (id: ${id})\n` +
'This means you can ignore any previous messages of the form ' +
`"Possible Unhandled Promise Rejection (id: ${id}):"`,
);
- },
- };
+ }
+ },
+};
+
+function attachUnhandledRejectionHandler(): void {
+ const tracking = requireRejectionTracking();
tracking.enable({
allRejections: true,
- onUnhandled: (id: string, error: unknown) => {
- if (__DEV__) {
- promiseRejectionTrackingOptions.onUnhandled(id, error);
- }
-
- captureException(error, {
- data: { id },
- originalException: error,
- syntheticException: isErrorLike(error) ? undefined : createSyntheticError(),
- mechanism: { handled: true, type: 'onunhandledrejection' },
- });
- },
- onHandled: (id: string) => {
- promiseRejectionTrackingOptions.onHandled(id);
- },
+ onUnhandled: promiseRejectionTrackingOptions.onUnhandled,
+ onHandled: promiseRejectionTrackingOptions.onHandled,
});
}
diff --git a/packages/core/src/js/profiling/integration.ts b/packages/core/src/js/profiling/integration.ts
index e2e4134fe6..14c46721c1 100644
--- a/packages/core/src/js/profiling/integration.ts
+++ b/packages/core/src/js/profiling/integration.ts
@@ -315,9 +315,7 @@ export function addNativeProfileToHermesProfile(
return {
...hermes,
profile: addNativeThreadCpuProfileToHermes(hermes.profile, native.profile, hermes.transaction.active_thread_id),
- debug_meta: {
- images: native.debug_meta.images,
- },
+ ...(native.debug_meta?.images ? { debug_meta: { images: native.debug_meta.images } } : {}),
measurements: native.measurements,
};
}
diff --git a/packages/core/src/js/profiling/nativeTypes.ts b/packages/core/src/js/profiling/nativeTypes.ts
index 06a9d3da4c..583fd363ee 100644
--- a/packages/core/src/js/profiling/nativeTypes.ts
+++ b/packages/core/src/js/profiling/nativeTypes.ts
@@ -39,7 +39,7 @@ export interface NativeProfileEvent {
unit: string;
}
>;
- debug_meta: {
+ debug_meta?: {
images: {
type: 'macho';
debug_id: string;
diff --git a/packages/core/src/js/replay/CustomMask.tsx b/packages/core/src/js/replay/CustomMask.tsx
index a43a198be0..4608dfbe04 100644
--- a/packages/core/src/js/replay/CustomMask.tsx
+++ b/packages/core/src/js/replay/CustomMask.tsx
@@ -3,6 +3,8 @@ import * as React from 'react';
import type { HostComponent, ViewProps } from 'react-native';
import { UIManager, View } from 'react-native';
+import { isExpoGo } from '../utils/environment';
+
const NativeComponentRegistry: {
get>(componentName: string, createViewConfig: () => C): HostComponent;
// eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -35,7 +37,7 @@ const UnmaskFallback = (viewProps: ViewProps): React.ReactElement => {
const hasViewManagerConfig = (nativeComponentName: string): boolean => UIManager.hasViewManagerConfig && UIManager.hasViewManagerConfig(nativeComponentName);
const Mask = ((): HostComponent | React.ComponentType => {
- if (!hasViewManagerConfig(MaskNativeComponentName)) {
+ if (isExpoGo() || !hasViewManagerConfig(MaskNativeComponentName)) {
logger.warn(`[SentrySessionReplay] Can't load ${MaskNativeComponentName}.`);
return MaskFallback;
}
@@ -48,7 +50,7 @@ const Mask = ((): HostComponent | React.ComponentType => {
})()
const Unmask = ((): HostComponent | React.ComponentType => {
- if (!hasViewManagerConfig(UnmaskNativeComponentName)) {
+ if (isExpoGo() || !hasViewManagerConfig(UnmaskNativeComponentName)) {
logger.warn(`[SentrySessionReplay] Can't load ${UnmaskNativeComponentName}.`);
return UnmaskFallback;
}
diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts
index 4f4501b138..79aa1117ec 100644
--- a/packages/core/src/js/replay/mobilereplay.ts
+++ b/packages/core/src/js/replay/mobilereplay.ts
@@ -31,14 +31,69 @@ export interface MobileReplayOptions {
* @default true
*/
maskAllVectors?: boolean;
+
+ /**
+ * Enables the up to 5x faster experimental view renderer used by the Session Replay integration on iOS.
+ *
+ * Enabling this flag will reduce the amount of time it takes to render each frame of the session replay on the main thread, therefore reducing
+ * interruptions and visual lag.
+ *
+ * - Experiment: This is an experimental feature and is therefore disabled by default.
+ *
+ * @deprecated Use `enableViewRendererV2` instead.
+ */
+ enableExperimentalViewRenderer?: boolean;
+
+ /**
+ * Enables up to 5x faster new view renderer used by the Session Replay integration on iOS.
+ *
+ * Enabling this flag will reduce the amount of time it takes to render each frame of the session replay on the main thread, therefore reducing
+ * interruptions and visual lag. [Our benchmarks](https://github.com/getsentry/sentry-cocoa/pull/4940) have shown a significant improvement of
+ * **up to 4-5x faster rendering** (reducing `~160ms` to `~36ms` per frame) on older devices.
+ *
+ * - Experiment: In case you are noticing issues with the new view renderer, please report the issue on [GitHub](https://github.com/getsentry/sentry-cocoa).
+ * Eventually, we will remove this feature flag and use the new view renderer by default.
+ *
+ * @default true
+ */
+ enableViewRendererV2?: boolean;
+
+ /**
+ * Enables up to 5x faster but incomplete view rendering used by the Session Replay integration on iOS.
+ *
+ * Enabling this flag will reduce the amount of time it takes to render each frame of the session replay on the main thread, therefore reducing
+ * interruptions and visual lag.
+ *
+ * - Note: This flag can only be used together with `enableExperimentalViewRenderer` with up to 20% faster render times.
+ * - Experiment: This is an experimental feature and is therefore disabled by default.
+ *
+ * @default false
+ */
+ enableFastViewRendering?: boolean;
}
const defaultOptions: Required = {
maskAllText: true,
maskAllImages: true,
maskAllVectors: true,
+ enableExperimentalViewRenderer: false,
+ enableViewRendererV2: true,
+ enableFastViewRendering: false,
};
+function mergeOptions(initOptions: Partial): Required {
+ const merged = {
+ ...defaultOptions,
+ ...initOptions,
+ };
+
+ if (initOptions.enableViewRendererV2 === undefined && initOptions.enableExperimentalViewRenderer !== undefined) {
+ merged.enableViewRendererV2 = initOptions.enableExperimentalViewRenderer;
+ }
+
+ return merged;
+}
+
type MobileReplayIntegration = Integration & {
options: Required;
};
@@ -73,7 +128,7 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
return mobileReplayIntegrationNoop();
}
- const options = { ...defaultOptions, ...initOptions };
+ const options = mergeOptions(initOptions);
async function processEvent(event: Event): Promise {
const hasException = event.exception && event.exception.values && event.exception.values.length > 0;
diff --git a/packages/core/src/js/sdk.tsx b/packages/core/src/js/sdk.tsx
index ceb4c7e263..3bdb27aa22 100644
--- a/packages/core/src/js/sdk.tsx
+++ b/packages/core/src/js/sdk.tsx
@@ -8,7 +8,7 @@ import {
import * as React from 'react';
import { ReactNativeClient } from './client';
-import { FeedbackWidgetProvider } from './feedback/FeedbackWidgetManager';
+import { FeedbackWidgetProvider } from './feedback/FeedbackWidgetProvider';
import { getDevServer } from './integrations/debugsymbolicatorutils';
import { getDefaultIntegrations } from './integrations/default';
import type { ReactNativeClientOptions, ReactNativeOptions, ReactNativeWrapperOptions } from './options';
diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts
index aa4a2bfbb5..4221172b53 100644
--- a/packages/core/src/js/tools/metroconfig.ts
+++ b/packages/core/src/js/tools/metroconfig.ts
@@ -228,7 +228,7 @@ export function withSentryResolver(config: MetroConfig, includeWebReplay: boolea
if (
(includeWebReplay === false ||
(includeWebReplay === undefined && (platform === 'android' || platform === 'ios'))) &&
- (oldMetroModuleName ?? moduleName).includes('@sentry/replay')
+ !!(oldMetroModuleName ?? moduleName).match(/@sentry(?:-internal)?\/replay/)
) {
return { type: 'empty' } as Resolution;
}
diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts
index 0f96557d4c..df0713bc57 100644
--- a/packages/core/src/js/tracing/integrations/appStart.ts
+++ b/packages/core/src/js/tracing/integrations/appStart.ts
@@ -1,5 +1,5 @@
-/* eslint-disable complexity */
-import type { Client, Event, Integration, SpanJSON, TransactionEvent } from '@sentry/core';
+/* eslint-disable complexity, max-lines */
+import type { Client, Event, Integration, Span, SpanJSON, TransactionEvent } from '@sentry/core';
import {
getCapturedScopesOnSpan,
getClient,
@@ -11,13 +11,14 @@ import {
timestampInSeconds,
} from '@sentry/core';
+import { getAppRegistryIntegration } from '../../integrations/appRegistry';
import {
APP_START_COLD as APP_START_COLD_MEASUREMENT,
APP_START_WARM as APP_START_WARM_MEASUREMENT,
} from '../../measurements';
import type { NativeAppStartResponse } from '../../NativeRNSentry';
import type { ReactNativeClientOptions } from '../../options';
-import { convertSpanToTransaction, setEndTimeValue } from '../../utils/span';
+import { convertSpanToTransaction, isRootSpan, setEndTimeValue } from '../../utils/span';
import { NATIVE } from '../../wrapper';
import {
APP_START_COLD as APP_START_COLD_OP,
@@ -26,6 +27,7 @@ import {
} from '../ops';
import { SPAN_ORIGIN_AUTO_APP_START, SPAN_ORIGIN_MANUAL_APP_START } from '../origin';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../semanticAttributes';
+import { setMainThreadInfo } from '../span';
import { createChildSpanJSON, createSpanJSON, getBundleStartTimestampMs } from '../utils';
const INTEGRATION_NAME = 'AppStart';
@@ -136,20 +138,40 @@ export const appStartIntegration = ({
let _client: Client | undefined = undefined;
let isEnabled = true;
let appStartDataFlushed = false;
+ let afterAllSetupCalled = false;
+ let firstStartedActiveRootSpanId: string | undefined = undefined;
const setup = (client: Client): void => {
_client = client;
- const clientOptions = client.getOptions() as ReactNativeClientOptions;
+ const { enableAppStartTracking } = client.getOptions() as ReactNativeClientOptions;
- const { enableAppStartTracking } = clientOptions;
if (!enableAppStartTracking) {
isEnabled = false;
logger.warn('[AppStart] App start tracking is disabled.');
}
+
+ client.on('spanStart', recordFirstStartedActiveRootSpanId);
};
- const afterAllSetup = (_client: Client): void => {
+ const afterAllSetup = (client: Client): void => {
+ if (afterAllSetupCalled) {
+ return;
+ }
+ afterAllSetupCalled = true;
+
// TODO: automatically set standalone based on the presence of the native layer navigation integration
+
+ getAppRegistryIntegration(client)?.onRunApplication(() => {
+ if (appStartDataFlushed) {
+ logger.log('[AppStartIntegration] Resetting app start data flushed flag based on runApplication call.');
+ appStartDataFlushed = false;
+ firstStartedActiveRootSpanId = undefined;
+ } else {
+ logger.log(
+ '[AppStartIntegration] Waiting for initial app start was flush, before updating based on runApplication call.',
+ );
+ }
+ });
};
const processEvent = async (event: Event): Promise => {
@@ -167,6 +189,27 @@ export const appStartIntegration = ({
return event;
};
+ const recordFirstStartedActiveRootSpanId = (rootSpan: Span): void => {
+ if (firstStartedActiveRootSpanId) {
+ return;
+ }
+
+ if (!isRootSpan(rootSpan)) {
+ return;
+ }
+
+ setFirstStartedActiveRootSpanId(rootSpan.spanContext().spanId);
+ };
+
+ /**
+ * For testing purposes only.
+ * @private
+ */
+ const setFirstStartedActiveRootSpanId = (spanId: string | undefined): void => {
+ firstStartedActiveRootSpanId = spanId;
+ logger.debug('[AppStart] First started active root span id recorded.', firstStartedActiveRootSpanId);
+ };
+
async function captureStandaloneAppStart(): Promise {
if (!standalone) {
logger.debug(
@@ -208,7 +251,12 @@ export const appStartIntegration = ({
async function attachAppStartToTransactionEvent(event: TransactionEvent): Promise {
if (appStartDataFlushed) {
- // App start data is only relevant for the first transaction
+ // App start data is only relevant for the first transaction of the app run
+ return;
+ }
+
+ if (!firstStartedActiveRootSpanId) {
+ logger.warn('[AppStart] No first started active root span id recorded. Can not attach app start.');
return;
}
@@ -217,6 +265,13 @@ export const appStartIntegration = ({
return;
}
+ if (firstStartedActiveRootSpanId !== event.contexts.trace.span_id) {
+ logger.warn(
+ '[AppStart] First started active root span id does not match the transaction event span id. Can not attached app start.',
+ );
+ return;
+ }
+
const appStart = await NATIVE.fetchNativeAppStart();
if (!appStart) {
logger.warn('[AppStart] Failed to retrieve the app start metrics from the native layer.');
@@ -242,7 +297,7 @@ export const appStartIntegration = ({
}
const isAppStartWithinBounds =
- !!event.start_timestamp && appStartTimestampMs >= event.start_timestamp - MAX_APP_START_AGE_MS;
+ !!event.start_timestamp && appStartTimestampMs >= event.start_timestamp * 1_000 - MAX_APP_START_AGE_MS;
if (!__DEV__ && !isAppStartWithinBounds) {
logger.warn('[AppStart] App start timestamp is too far in the past to be used for app start span.');
return;
@@ -255,6 +310,17 @@ export const appStartIntegration = ({
return;
}
+ if (appStartDurationMs < 0) {
+ // This can happen when MainActivity on Android is recreated,
+ // and the app start end timestamp is not updated, for example
+ // due to missing `Sentry.wrap(RootComponent)` call.
+ logger.warn(
+ '[AppStart] Last recorded app start end timestamp is before the app start timestamp.',
+ 'This is usually caused by missing `Sentry.wrap(RootComponent)` call.',
+ );
+ return;
+ }
+
appStartDataFlushed = true;
event.contexts.trace.data = event.contexts.trace.data || {};
@@ -332,7 +398,8 @@ export const appStartIntegration = ({
afterAllSetup,
processEvent,
captureStandaloneAppStart,
- };
+ setFirstStartedActiveRootSpanId,
+ } as AppStartIntegration;
};
function setSpanDurationAsMeasurementOnTransactionEvent(event: TransactionEvent, label: string, span: SpanJSON): void {
@@ -360,19 +427,25 @@ function createJSExecutionStartSpan(
return undefined;
}
+ const bundleStartTimestampSeconds = bundleStartTimestampMs / 1000;
+ if (bundleStartTimestampSeconds < parentSpan.start_timestamp) {
+ logger.warn('Bundle start timestamp is before the app start span start timestamp. Skipping JS execution span.');
+ return undefined;
+ }
+
if (!rootComponentCreationTimestampMs) {
logger.warn('Missing the root component first constructor call timestamp.');
return createChildSpanJSON(parentSpan, {
description: 'JS Bundle Execution Start',
- start_timestamp: bundleStartTimestampMs / 1000,
- timestamp: bundleStartTimestampMs / 1000,
+ start_timestamp: bundleStartTimestampSeconds,
+ timestamp: bundleStartTimestampSeconds,
origin: SPAN_ORIGIN_AUTO_APP_START,
});
}
return createChildSpanJSON(parentSpan, {
description: 'JS Bundle Execution Before React Root',
- start_timestamp: bundleStartTimestampMs / 1000,
+ start_timestamp: bundleStartTimestampSeconds,
timestamp: rootComponentCreationTimestampMs / 1000,
origin: isRootComponentCreationTimestampMsManual ? SPAN_ORIGIN_MANUAL_APP_START : SPAN_ORIGIN_AUTO_APP_START,
});
@@ -382,18 +455,22 @@ function createJSExecutionStartSpan(
* Adds native spans to the app start span.
*/
function convertNativeSpansToSpanJSON(parentSpan: SpanJSON, nativeSpans: NativeAppStartResponse['spans']): SpanJSON[] {
- return nativeSpans.map(span => {
- if (span.description === 'UIKit init') {
- return createUIKitSpan(parentSpan, span);
- }
-
- return createChildSpanJSON(parentSpan, {
- description: span.description,
- start_timestamp: span.start_timestamp_ms / 1000,
- timestamp: span.end_timestamp_ms / 1000,
- origin: SPAN_ORIGIN_AUTO_APP_START,
+ return nativeSpans
+ .filter(span => span.start_timestamp_ms / 1000 >= parentSpan.start_timestamp)
+ .map(span => {
+ if (span.description === 'UIKit init') {
+ return setMainThreadInfo(createUIKitSpan(parentSpan, span));
+ }
+
+ return setMainThreadInfo(
+ createChildSpanJSON(parentSpan, {
+ description: span.description,
+ start_timestamp: span.start_timestamp_ms / 1000,
+ timestamp: span.end_timestamp_ms / 1000,
+ origin: SPAN_ORIGIN_AUTO_APP_START,
+ }),
+ );
});
- });
}
/**
diff --git a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts
new file mode 100644
index 0000000000..52cd915634
--- /dev/null
+++ b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts
@@ -0,0 +1,239 @@
+import type { Event, Integration, SpanJSON } from '@sentry/core';
+import { logger } from '@sentry/core';
+
+import { NATIVE } from '../../wrapper';
+import { UI_LOAD_FULL_DISPLAY, UI_LOAD_INITIAL_DISPLAY } from '../ops';
+import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../origin';
+import { getReactNavigationIntegration } from '../reactnavigation';
+import { SEMANTIC_ATTRIBUTE_ROUTE_HAS_BEEN_SEEN } from '../semanticAttributes';
+import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../span';
+import { getTimeToInitialDisplayFallback } from '../timeToDisplayFallback';
+import { createSpanJSON } from '../utils';
+
+export const INTEGRATION_NAME = 'TimeToDisplay';
+
+const TIME_TO_DISPLAY_TIMEOUT_MS = 30_000;
+const isDeadlineExceeded = (durationMs: number): boolean => durationMs > TIME_TO_DISPLAY_TIMEOUT_MS;
+
+export const timeToDisplayIntegration = (): Integration => {
+ let enableTimeToInitialDisplayForPreloadedRoutes = false;
+
+ return {
+ name: INTEGRATION_NAME,
+ afterAllSetup(client) {
+ enableTimeToInitialDisplayForPreloadedRoutes =
+ getReactNavigationIntegration(client)?.options.enableTimeToInitialDisplayForPreloadedRoutes ?? false;
+ },
+ processEvent: async event => {
+ if (event.type !== 'transaction') {
+ // TimeToDisplay data is only relevant for transactions
+ return event;
+ }
+
+ const rootSpanId = event.contexts.trace.span_id;
+ if (!rootSpanId) {
+ logger.warn(`[${INTEGRATION_NAME}] No root span id found in transaction.`);
+ return event;
+ }
+
+ const transactionStartTimestampSeconds = event.start_timestamp;
+ if (!transactionStartTimestampSeconds) {
+ // This should never happen
+ logger.warn(`[${INTEGRATION_NAME}] No transaction start timestamp found in transaction.`);
+ return event;
+ }
+
+ event.spans = event.spans || [];
+ event.measurements = event.measurements || {};
+
+ const ttidSpan = await addTimeToInitialDisplay({
+ event,
+ rootSpanId,
+ transactionStartTimestampSeconds,
+ enableTimeToInitialDisplayForPreloadedRoutes,
+ });
+ const ttfdSpan = await addTimeToFullDisplay({ event, rootSpanId, transactionStartTimestampSeconds, ttidSpan });
+
+ if (ttidSpan && ttidSpan.start_timestamp && ttidSpan.timestamp) {
+ event.measurements['time_to_initial_display'] = {
+ value: (ttidSpan.timestamp - ttidSpan.start_timestamp) * 1000,
+ unit: 'millisecond',
+ };
+ }
+
+ if (ttfdSpan && ttfdSpan.start_timestamp && ttfdSpan.timestamp) {
+ const durationMs = (ttfdSpan.timestamp - ttfdSpan.start_timestamp) * 1000;
+ if (isDeadlineExceeded(durationMs)) {
+ event.measurements['time_to_full_display'] = event.measurements['time_to_initial_display'];
+ } else {
+ event.measurements['time_to_full_display'] = {
+ value: durationMs,
+ unit: 'millisecond',
+ };
+ }
+ }
+
+ const newTransactionEndTimestampSeconds = Math.max(
+ ttidSpan?.timestamp ?? -1,
+ ttfdSpan?.timestamp ?? -1,
+ event.timestamp ?? -1,
+ );
+ if (newTransactionEndTimestampSeconds !== -1) {
+ event.timestamp = newTransactionEndTimestampSeconds;
+ }
+
+ return event;
+ },
+ };
+};
+
+async function addTimeToInitialDisplay({
+ event,
+ rootSpanId,
+ transactionStartTimestampSeconds,
+ enableTimeToInitialDisplayForPreloadedRoutes,
+}: {
+ event: Event;
+ rootSpanId: string;
+ transactionStartTimestampSeconds: number;
+ enableTimeToInitialDisplayForPreloadedRoutes: boolean;
+}): Promise {
+ const ttidEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttid-${rootSpanId}`);
+
+ let ttidSpan: SpanJSON | undefined = event.spans?.find(span => span.op === UI_LOAD_INITIAL_DISPLAY);
+
+ if (ttidSpan && (ttidSpan.status === undefined || ttidSpan.status === 'ok') && !ttidEndTimestampSeconds) {
+ logger.debug(`[${INTEGRATION_NAME}] Ttid span already exists and is ok.`, ttidSpan);
+ return ttidSpan;
+ }
+
+ if (!ttidEndTimestampSeconds) {
+ logger.debug(`[${INTEGRATION_NAME}] No manual ttid end timestamp found for span ${rootSpanId}.`);
+ return addAutomaticTimeToInitialDisplay({
+ event,
+ rootSpanId,
+ transactionStartTimestampSeconds,
+ enableTimeToInitialDisplayForPreloadedRoutes,
+ });
+ }
+
+ if (ttidSpan && ttidSpan.status && ttidSpan.status !== 'ok') {
+ ttidSpan.status = 'ok';
+ ttidSpan.timestamp = ttidEndTimestampSeconds;
+ logger.debug(`[${INTEGRATION_NAME}] Updated existing ttid span.`, ttidSpan);
+ return ttidSpan;
+ }
+
+ ttidSpan = createSpanJSON({
+ op: UI_LOAD_INITIAL_DISPLAY,
+ description: 'Time To Initial Display',
+ start_timestamp: transactionStartTimestampSeconds,
+ timestamp: ttidEndTimestampSeconds,
+ origin: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY,
+ parent_span_id: rootSpanId,
+ data: {
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
+ },
+ });
+ logger.debug(`[${INTEGRATION_NAME}] Added ttid span to transaction.`, ttidSpan);
+ event.spans.push(ttidSpan);
+ return ttidSpan;
+}
+
+async function addAutomaticTimeToInitialDisplay({
+ event,
+ rootSpanId,
+ transactionStartTimestampSeconds,
+ enableTimeToInitialDisplayForPreloadedRoutes,
+}: {
+ event: Event;
+ rootSpanId: string;
+ transactionStartTimestampSeconds: number;
+ enableTimeToInitialDisplayForPreloadedRoutes: boolean;
+}): Promise {
+ const ttidNativeTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttid-navigation-${rootSpanId}`);
+ const ttidFallbackTimestampSeconds = await getTimeToInitialDisplayFallback(rootSpanId);
+
+ const hasBeenSeen = event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_ROUTE_HAS_BEEN_SEEN];
+ if (hasBeenSeen && !enableTimeToInitialDisplayForPreloadedRoutes) {
+ logger.debug(
+ `[${INTEGRATION_NAME}] Route has been seen and time to initial display is disabled for preloaded routes.`,
+ );
+ return undefined;
+ }
+
+ const ttidTimestampSeconds = ttidNativeTimestampSeconds ?? ttidFallbackTimestampSeconds;
+ if (!ttidTimestampSeconds) {
+ logger.debug(`[${INTEGRATION_NAME}] No automatic ttid end timestamp found for span ${rootSpanId}.`);
+ return undefined;
+ }
+
+ const viewNames = event.contexts?.app?.view_names;
+ const screenName = Array.isArray(viewNames) ? viewNames[0] : viewNames;
+
+ const ttidSpan = createSpanJSON({
+ op: UI_LOAD_INITIAL_DISPLAY,
+ description: screenName ? `${screenName} initial display` : 'Time To Initial Display',
+ start_timestamp: transactionStartTimestampSeconds,
+ timestamp: ttidTimestampSeconds,
+ origin: SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY,
+ parent_span_id: rootSpanId,
+ data: {
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
+ },
+ });
+ event.spans = event.spans ?? [];
+ event.spans.push(ttidSpan);
+ return ttidSpan;
+}
+
+async function addTimeToFullDisplay({
+ event,
+ rootSpanId,
+ transactionStartTimestampSeconds,
+ ttidSpan,
+}: {
+ event: Event;
+ rootSpanId: string;
+ transactionStartTimestampSeconds: number;
+ ttidSpan: SpanJSON | undefined;
+}): Promise {
+ const ttfdEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`);
+
+ if (!ttidSpan || !ttfdEndTimestampSeconds) {
+ return undefined;
+ }
+
+ let ttfdSpan = event.spans?.find(span => span.op === UI_LOAD_FULL_DISPLAY);
+
+ let ttfdAdjustedEndTimestampSeconds = ttfdEndTimestampSeconds;
+ const ttfdIsBeforeTtid = ttidSpan?.timestamp && ttfdEndTimestampSeconds < ttidSpan.timestamp;
+ if (ttfdIsBeforeTtid) {
+ ttfdAdjustedEndTimestampSeconds = ttidSpan.timestamp;
+ }
+
+ const durationMs = (ttfdAdjustedEndTimestampSeconds - transactionStartTimestampSeconds) * 1000;
+
+ if (ttfdSpan && ttfdSpan.status && ttfdSpan.status !== 'ok') {
+ ttfdSpan.status = 'ok';
+ ttfdSpan.timestamp = ttfdAdjustedEndTimestampSeconds;
+ logger.debug(`[${INTEGRATION_NAME}] Updated existing ttfd span.`, ttfdSpan);
+ return ttfdSpan;
+ }
+
+ ttfdSpan = createSpanJSON({
+ status: isDeadlineExceeded(durationMs) ? 'deadline_exceeded' : 'ok',
+ op: UI_LOAD_FULL_DISPLAY,
+ description: 'Time To Full Display',
+ start_timestamp: transactionStartTimestampSeconds,
+ timestamp: ttfdAdjustedEndTimestampSeconds,
+ origin: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY,
+ parent_span_id: rootSpanId,
+ data: {
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
+ },
+ });
+ logger.debug(`[${INTEGRATION_NAME}] Added ttfd span to transaction.`, ttfdSpan);
+ event.spans.push(ttfdSpan);
+ return ttfdSpan;
+}
diff --git a/packages/core/src/js/tracing/ops.ts b/packages/core/src/js/tracing/ops.ts
index 0f574d89b9..79c7c239b1 100644
--- a/packages/core/src/js/tracing/ops.ts
+++ b/packages/core/src/js/tracing/ops.ts
@@ -7,3 +7,6 @@ export const UI_ACTION_TOUCH = 'ui.action.touch';
export const APP_START_COLD = 'app.start.cold';
export const APP_START_WARM = 'app.start.warm';
+
+export const UI_LOAD_INITIAL_DISPLAY = 'ui.load.initial_display';
+export const UI_LOAD_FULL_DISPLAY = 'ui.load.full_display';
diff --git a/packages/core/src/js/tracing/reactnativeprofiler.tsx b/packages/core/src/js/tracing/reactnativeprofiler.tsx
index 35b5d3073d..ed5a9158e8 100644
--- a/packages/core/src/js/tracing/reactnativeprofiler.tsx
+++ b/packages/core/src/js/tracing/reactnativeprofiler.tsx
@@ -1,11 +1,15 @@
-import { timestampInSeconds } from '@sentry/core';
+import { logger, timestampInSeconds } from '@sentry/core';
import { getClient, Profiler } from '@sentry/react';
+import { getAppRegistryIntegration } from '../integrations/appRegistry';
import { createIntegration } from '../integrations/factory';
import { _captureAppStart, _setRootComponentCreationTimestampMs } from '../tracing/integrations/appStart';
const ReactNativeProfilerGlobalState = {
appStartReported: false,
+ onRunApplicationHook: () => {
+ ReactNativeProfilerGlobalState.appStartReported = false;
+ },
};
/**
@@ -44,6 +48,14 @@ export class ReactNativeProfiler extends Profiler {
}
client.addIntegration && client.addIntegration(createIntegration(this.name));
+
+ const appRegistryIntegration = getAppRegistryIntegration(client);
+ if (appRegistryIntegration && typeof appRegistryIntegration.onRunApplication === 'function') {
+ appRegistryIntegration.onRunApplication(ReactNativeProfilerGlobalState.onRunApplicationHook);
+ } else {
+ logger.warn('AppRegistryIntegration.onRunApplication not found or invalid.');
+ }
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
_captureAppStart({ isManual: false });
}
diff --git a/packages/core/src/js/tracing/reactnativetracing.ts b/packages/core/src/js/tracing/reactnativetracing.ts
index d9ada0ff9d..8874f6769f 100644
--- a/packages/core/src/js/tracing/reactnativetracing.ts
+++ b/packages/core/src/js/tracing/reactnativetracing.ts
@@ -5,7 +5,7 @@ import { getClient } from '@sentry/core';
import { isWeb } from '../utils/environment';
import { getDevServer } from './../integrations/debugsymbolicatorutils';
-import { addDefaultOpForSpanFrom, defaultIdleOptions } from './span';
+import { addDefaultOpForSpanFrom, addThreadInfoToSpan, defaultIdleOptions } from './span';
export const INTEGRATION_NAME = 'ReactNativeTracing';
@@ -29,7 +29,10 @@ export interface ReactNativeTracingOptions {
/**
* Flag to disable patching all together for fetch requests.
*
- * @default true
+ * Fetch in React Native is a `whatwg-fetch` polyfill which uses XHR under the hood.
+ * This causes duplicates when both `traceFetch` and `traceXHR` are enabled at the same time.
+ *
+ * @default false
*/
traceFetch: boolean;
@@ -70,7 +73,11 @@ function getDefaultTracePropagationTargets(): RegExp[] | undefined {
}
export const defaultReactNativeTracingOptions: ReactNativeTracingOptions = {
- traceFetch: true,
+ // Fetch in React Native is a `whatwg-fetch` polyfill which uses XHR under the hood.
+ // This causes duplicates when both `traceFetch` and `traceXHR` are enabled at the same time.
+ // https://github.com/facebook/react-native/blob/28945c68da056ab2ac01de7e542a845b2bca6096/packages/react-native/Libraries/Network/fetch.js
+ // (RN Web uses browsers native fetch implementation)
+ traceFetch: isWeb() ? true : false,
traceXHR: true,
enableHTTPTimings: true,
};
@@ -119,6 +126,7 @@ export const reactNativeTracingIntegration = (
const setup = (client: Client): void => {
addDefaultOpForSpanFrom(client);
+ addThreadInfoToSpan(client);
instrumentOutgoingRequests(client, {
traceFetch: finalOptions.traceFetch,
diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts
index dd33424b9c..58afcf1f4a 100644
--- a/packages/core/src/js/tracing/reactnavigation.ts
+++ b/packages/core/src/js/tracing/reactnavigation.ts
@@ -2,7 +2,6 @@
import type { Client, Integration, Span } from '@sentry/core';
import {
addBreadcrumb,
- getActiveSpan,
getClient,
isPlainObject,
logger,
@@ -14,25 +13,23 @@ import {
timestampInSeconds,
} from '@sentry/core';
-import type { NewFrameEvent } from '../utils/sentryeventemitter';
-import type { SentryEventEmitterFallback } from '../utils/sentryeventemitterfallback';
-import { createSentryFallbackEventEmitter } from '../utils/sentryeventemitterfallback';
+import { getAppRegistryIntegration } from '../integrations/appRegistry';
import { isSentrySpan } from '../utils/span';
import { RN_GLOBAL_OBJ } from '../utils/worldwide';
+import type { UnsafeAction } from '../vendor/react-navigation/types';
import { NATIVE } from '../wrapper';
import { ignoreEmptyBackNavigation } from './onSpanEndUtils';
import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from './origin';
import type { ReactNativeTracingIntegration } from './reactnativetracing';
import { getReactNativeTracingIntegration } from './reactnativetracing';
-import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes';
+import { SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes';
import {
DEFAULT_NAVIGATION_SPAN_NAME,
defaultIdleOptions,
getDefaultIdleNavigationSpanOptions,
startIdleNavigationSpan as startGenericIdleNavigationSpan,
} from './span';
-import { manualInitialDisplaySpans, startTimeToInitialDisplaySpan } from './timetodisplay';
-import { setSpanDurationAsMeasurementOnSpan } from './utils';
+import { addTimeToInitialDisplayFallback } from './timeToDisplayFallback';
export const INTEGRATION_NAME = 'ReactNavigation';
const NAVIGATION_HISTORY_MAX_SIZE = 200;
@@ -61,6 +58,21 @@ interface ReactNavigationIntegrationOptions {
* @default true
*/
ignoreEmptyBackNavigationTransactions: boolean;
+
+ /**
+ * Enabled measuring Time to Initial Display for routes that are already loaded in memory.
+ * (a.k.a., Routes that the navigation integration has already seen.)
+ *
+ * @default false
+ */
+ enableTimeToInitialDisplayForPreloadedRoutes: boolean;
+
+ /**
+ * Whether to use the dispatched action data to populate the transaction metadata.
+ *
+ * @default false
+ */
+ useDispatchedActionData: boolean;
}
/**
@@ -75,15 +87,17 @@ export const reactNavigationIntegration = ({
routeChangeTimeoutMs = 1_000,
enableTimeToInitialDisplay = false,
ignoreEmptyBackNavigationTransactions = true,
+ enableTimeToInitialDisplayForPreloadedRoutes = false,
+ useDispatchedActionData = false,
}: Partial = {}): Integration & {
/**
* Pass the ref to the navigation container to register it to the instrumentation
* @param navigationContainerRef Ref to a `NavigationContainer`
*/
registerNavigationContainer: (navigationContainerRef: unknown) => void;
+ options: ReactNavigationIntegrationOptions;
} => {
let navigationContainer: NavigationContainer | undefined;
- let newScreenFrameEventEmitter: SentryEventEmitterFallback | undefined;
let tracing: ReactNativeTracingIntegration | undefined;
let idleSpanOptions: Parameters[1] = defaultIdleOptions;
@@ -97,8 +111,6 @@ export const reactNavigationIntegration = ({
let recentRouteKeys: string[] = [];
if (enableTimeToInitialDisplay) {
- newScreenFrameEventEmitter = createSentryFallbackEventEmitter();
- newScreenFrameEventEmitter.initAsync();
NATIVE.initNativeReactNavigationNewFrameTracking().catch((reason: unknown) => {
logger.error(`${INTEGRATION_NAME} Failed to initialize native new frame tracking: ${reason}`);
});
@@ -118,9 +130,21 @@ export const reactNavigationIntegration = ({
if (initialStateHandled) {
// We create an initial state here to ensure a transaction gets created before the first route mounts.
+ // This assumes that the Sentry.init() call is made before the first route mounts.
+ // If this is not the case, the first transaction will be nameless 'Route Changed'
return undefined;
}
+ getAppRegistryIntegration(client)?.onRunApplication(() => {
+ if (initialStateHandled) {
+ // To avoid conflict with the initial transaction we check if it was already handled.
+ // This ensures runApplication calls after the initial start are correctly traced.
+ // This is used for example when Activity is (re)started on Android.
+ logger.log('[ReactNavigationIntegration] Starting new idle navigation span based on runApplication call.');
+ startIdleNavigationSpan();
+ }
+ });
+
startIdleNavigationSpan();
if (!navigationContainer) {
@@ -133,24 +157,28 @@ export const reactNavigationIntegration = ({
initialStateHandled = true;
};
- const registerNavigationContainer = (navigationContainerRef: unknown): void => {
- /* We prevent duplicate routing instrumentation to be initialized on fast refreshes
-
- Explanation: If the user triggers a fast refresh on the file that the instrumentation is
- initialized in, it will initialize a new instance and will cause undefined behavior.
- */
+ const registerNavigationContainer = (maybeNewNavigationContainer: unknown): void => {
if (RN_GLOBAL_OBJ.__sentry_rn_v5_registered) {
- logger.log(
- `${INTEGRATION_NAME} Instrumentation already exists, but register has been called again, doing nothing.`,
- );
- return undefined;
+ logger.debug(`${INTEGRATION_NAME} Instrumentation already exists, but registering again...`);
+ // In the past we have not allowed re-registering the navigation container to avoid unexpected behavior.
+ // But this doesn't work for Android and re-recreating application main activity.
+ // Where new navigation container is created and the old one is discarded. We need to re-register to
+ // trace the new navigation container navigation.
}
- if (isPlainObject(navigationContainerRef) && 'current' in navigationContainerRef) {
- navigationContainer = navigationContainerRef.current as NavigationContainer;
+ let newNavigationContainer: NavigationContainer | undefined;
+ if (isPlainObject(maybeNewNavigationContainer) && 'current' in maybeNewNavigationContainer) {
+ newNavigationContainer = maybeNewNavigationContainer.current as NavigationContainer;
} else {
- navigationContainer = navigationContainerRef as NavigationContainer;
+ newNavigationContainer = maybeNewNavigationContainer as NavigationContainer;
}
+
+ if (navigationContainer === newNavigationContainer) {
+ logger.log(`${INTEGRATION_NAME} Navigation container ref is the same as the one already registered.`);
+ return;
+ }
+ navigationContainer = newNavigationContainer as NavigationContainer;
+
if (!navigationContainer) {
logger.warn(`${INTEGRATION_NAME} Received invalid navigation container ref!`);
return undefined;
@@ -182,7 +210,30 @@ export const reactNavigationIntegration = ({
* It does not name the transaction or populate it with route information. Instead, it waits for the state to fully change
* and gets the route information from there, @see updateLatestNavigationSpanWithCurrentRoute
*/
- const startIdleNavigationSpan = (): void => {
+ const startIdleNavigationSpan = (unknownEvent?: unknown): void => {
+ const event = unknownEvent as UnsafeAction | undefined;
+ if (useDispatchedActionData && event?.data.noop) {
+ logger.debug(`${INTEGRATION_NAME} Navigation action is a noop, not starting navigation span.`);
+ return;
+ }
+
+ const navigationActionType = useDispatchedActionData ? event?.data.action.type : undefined;
+ if (
+ useDispatchedActionData &&
+ [
+ // Process common actions
+ 'PRELOAD',
+ 'SET_PARAMS',
+ // Drawer actions
+ 'OPEN_DRAWER',
+ 'CLOSE_DRAWER',
+ 'TOGGLE_DRAWER',
+ ].includes(navigationActionType)
+ ) {
+ logger.debug(`${INTEGRATION_NAME} Navigation action is ${navigationActionType}, not starting navigation span.`);
+ return;
+ }
+
if (latestNavigationSpan) {
logger.log(`${INTEGRATION_NAME} A transaction was detected that turned out to be a noop, discarding.`);
_discardLatestTransaction();
@@ -196,11 +247,13 @@ export const reactNavigationIntegration = ({
idleSpanOptions,
);
latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION);
+ latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, navigationActionType);
if (ignoreEmptyBackNavigationTransactions) {
ignoreEmptyBackNavigation(getClient(), latestNavigationSpan);
}
if (enableTimeToInitialDisplay) {
+ NATIVE.setActiveSpanId(latestNavigationSpan?.spanContext().spanId);
navigationProcessingSpan = startInactiveSpan({
op: 'navigation.processing',
name: 'Navigation dispatch to navigation cancelled or screen mounted',
@@ -240,6 +293,8 @@ export const reactNavigationIntegration = ({
return undefined;
}
+ addTimeToInitialDisplayFallback(latestNavigationSpan.spanContext().spanId, NATIVE.getNewScreenTimeToDisplay());
+
if (previousRoute && previousRoute.key === route.key) {
logger.debug(`[${INTEGRATION_NAME}] Navigation state changed, but route is the same as previous.`);
pushRecentRouteKey(route.key);
@@ -251,28 +306,6 @@ export const reactNavigationIntegration = ({
}
const routeHasBeenSeen = recentRouteKeys.includes(route.key);
- const latestTtidSpan =
- !routeHasBeenSeen &&
- enableTimeToInitialDisplay &&
- startTimeToInitialDisplaySpan({
- name: `${route.name} initial display`,
- isAutoInstrumented: true,
- });
-
- const navigationSpanWithTtid = latestNavigationSpan;
- if (!routeHasBeenSeen && latestTtidSpan) {
- newScreenFrameEventEmitter?.onceNewFrame(({ newFrameTimestampInSeconds }: NewFrameEvent) => {
- const activeSpan = getActiveSpan();
- if (activeSpan && manualInitialDisplaySpans.has(activeSpan)) {
- logger.warn('[ReactNavigationInstrumentation] Detected manual instrumentation for the current active span.');
- return;
- }
-
- latestTtidSpan.setStatus({ code: SPAN_STATUS_OK });
- latestTtidSpan.end(newFrameTimestampInSeconds);
- setSpanDurationAsMeasurementOnSpan('time_to_initial_display', latestTtidSpan, navigationSpanWithTtid);
- });
- }
navigationProcessingSpan?.updateName(`Navigation dispatch to screen ${route.name} mounted`);
navigationProcessingSpan?.setStatus({ code: SPAN_STATUS_OK });
@@ -309,7 +342,7 @@ export const reactNavigationIntegration = ({
},
});
- tracing?.setCurrentRoute(route.key);
+ tracing?.setCurrentRoute(route.name);
pushRecentRouteKey(route.key);
latestRoute = route;
@@ -352,6 +385,13 @@ export const reactNavigationIntegration = ({
name: INTEGRATION_NAME,
afterAllSetup,
registerNavigationContainer,
+ options: {
+ routeChangeTimeoutMs,
+ enableTimeToInitialDisplay,
+ ignoreEmptyBackNavigationTransactions,
+ enableTimeToInitialDisplayForPreloadedRoutes,
+ useDispatchedActionData,
+ },
};
};
@@ -363,6 +403,15 @@ export interface NavigationRoute {
}
interface NavigationContainer {
- addListener: (type: string, listener: () => void) => void;
+ addListener: (type: string, listener: (event?: unknown) => void) => void;
getCurrentRoute: () => NavigationRoute;
}
+
+/**
+ * Returns React Navigation integration of the given client.
+ */
+export function getReactNavigationIntegration(
+ client: Client,
+): ReturnType | undefined {
+ return client.getIntegrationByName>(INTEGRATION_NAME);
+}
diff --git a/packages/core/src/js/tracing/semanticAttributes.ts b/packages/core/src/js/tracing/semanticAttributes.ts
index 6a0294ea3a..046d162e77 100644
--- a/packages/core/src/js/tracing/semanticAttributes.ts
+++ b/packages/core/src/js/tracing/semanticAttributes.ts
@@ -16,3 +16,5 @@ export const SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_NAME = 'previous_route.name';
export const SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_KEY = 'previous_route.key';
export const SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_COMPONENT_ID = 'previous_route.component_id';
export const SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_COMPONENT_TYPE = 'previous_route.component_type';
+export const SEMANTIC_ATTRIBUTE_TIME_TO_INITIAL_DISPLAY_FALLBACK = 'route.initial_display_fallback';
+export const SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE = 'navigation.action_type';
diff --git a/packages/core/src/js/tracing/span.ts b/packages/core/src/js/tracing/span.ts
index b44425691c..ac02dca769 100644
--- a/packages/core/src/js/tracing/span.ts
+++ b/packages/core/src/js/tracing/span.ts
@@ -1,4 +1,4 @@
-import type { Client, Scope, Span, StartSpanOptions } from '@sentry/core';
+import type { Client, Scope, Span, SpanJSON, StartSpanOptions } from '@sentry/core';
import {
generatePropagationContext,
getActiveSpan,
@@ -69,15 +69,15 @@ export const startIdleNavigationSpan = (
activeSpan.end();
}
- const finalStartStapOptions = {
+ const finalStartSpanOptions = {
...getDefaultIdleNavigationSpanOptions(),
...startSpanOption,
};
- const idleSpan = startIdleSpan(finalStartStapOptions, { finalTimeout, idleTimeout });
+ const idleSpan = startIdleSpan(finalStartSpanOptions, { finalTimeout, idleTimeout });
logger.log(
- `[startIdleNavigationSpan] Starting ${finalStartStapOptions.op || 'unknown op'} transaction "${
- finalStartStapOptions.name
+ `[startIdleNavigationSpan] Starting ${finalStartSpanOptions.op || 'unknown op'} transaction "${
+ finalStartSpanOptions.name
}" on scope`,
);
@@ -154,3 +154,28 @@ export function addDefaultOpForSpanFrom(client: Client): void {
}
});
}
+
+export const SPAN_THREAD_NAME = 'thread.name';
+export const SPAN_THREAD_NAME_MAIN = 'main';
+export const SPAN_THREAD_NAME_JAVASCRIPT = 'javascript';
+
+/**
+ * Adds Javascript thread info to spans.
+ * Ref: https://reactnative.dev/architecture/threading-model
+ */
+export function addThreadInfoToSpan(client: Client): void {
+ client.on('spanStart', (span: Span) => {
+ if (!spanToJSON(span).data?.[SPAN_THREAD_NAME]) {
+ span.setAttribute(SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT);
+ }
+ });
+}
+
+/**
+ * Sets the Main thread info to the span.
+ */
+export function setMainThreadInfo(spanJSON: SpanJSON): SpanJSON {
+ spanJSON.data = spanJSON.data || {};
+ spanJSON.data[SPAN_THREAD_NAME] = SPAN_THREAD_NAME_MAIN;
+ return spanJSON;
+}
diff --git a/packages/core/src/js/tracing/timeToDisplayFallback.ts b/packages/core/src/js/tracing/timeToDisplayFallback.ts
new file mode 100644
index 0000000000..cd71b21df0
--- /dev/null
+++ b/packages/core/src/js/tracing/timeToDisplayFallback.ts
@@ -0,0 +1,18 @@
+import { AsyncExpiringMap } from '../utils/AsyncExpiringMap';
+
+const TIME_TO_DISPLAY_FALLBACK_TTL_MS = 60_000;
+
+const spanIdToTimeToInitialDisplayFallback: AsyncExpiringMap = new AsyncExpiringMap({
+ ttl: TIME_TO_DISPLAY_FALLBACK_TTL_MS,
+});
+
+export const addTimeToInitialDisplayFallback = (
+ spanId: string,
+ timestampSeconds: Promise,
+): void => {
+ spanIdToTimeToInitialDisplayFallback.set(spanId, timestampSeconds);
+};
+
+export const getTimeToInitialDisplayFallback = async (spanId: string): Promise => {
+ return spanIdToTimeToInitialDisplayFallback.get(spanId);
+};
diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx
index 12d1198bc4..f33216b875 100644
--- a/packages/core/src/js/tracing/timetodisplay.tsx
+++ b/packages/core/src/js/tracing/timetodisplay.tsx
@@ -1,12 +1,12 @@
import type { Span,StartSpanOptions } from '@sentry/core';
import { fill, getActiveSpan, getSpanDescendants, logger, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, SPAN_STATUS_OK, spanToJSON, startInactiveSpan } from '@sentry/core';
import * as React from 'react';
+import { useState } from 'react';
import { isTurboModuleEnabled } from '../utils/environment';
import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from './origin';
import { getRNSentryOnDrawReporter, nativeComponentExists } from './timetodisplaynative';
-import type {RNSentryOnDrawNextFrameEvent } from './timetodisplaynative.types';
-import { setSpanDurationAsMeasurement } from './utils';
+import { setSpanDurationAsMeasurement, setSpanDurationAsMeasurementOnSpan } from './utils';
let nativeComponentMissingLogged = false;
@@ -36,10 +36,10 @@ export function TimeToInitialDisplay(props: TimeToDisplayProps): React.ReactElem
const activeSpan = getActiveSpan();
if (activeSpan) {
manualInitialDisplaySpans.set(activeSpan, true);
- startTimeToInitialDisplaySpan();
}
- return {props.children};
+ const parentSpanId = activeSpan && spanToJSON(activeSpan).span_id;
+ return {props.children};
}
/**
@@ -50,14 +50,16 @@ export function TimeToInitialDisplay(props: TimeToDisplayProps): React.ReactElem
*
*/
export function TimeToFullDisplay(props: TimeToDisplayProps): React.ReactElement {
- startTimeToFullDisplaySpan();
- return {props.children};
+ const activeSpan = getActiveSpan();
+ const parentSpanId = activeSpan && spanToJSON(activeSpan).span_id;
+ return {props.children};
}
function TimeToDisplay(props: {
children?: React.ReactNode;
initialDisplay?: boolean;
fullDisplay?: boolean;
+ parentSpanId?: string;
}): React.ReactElement {
const RNSentryOnDrawReporter = getRNSentryOnDrawReporter();
const isNewArchitecture = isTurboModuleEnabled();
@@ -71,14 +73,12 @@ function TimeToDisplay(props: {
}, 0);
}
- const onDraw = (event: { nativeEvent: RNSentryOnDrawNextFrameEvent }): void => onDrawNextFrame(event);
-
return (
<>
+ fullDisplay={props.fullDisplay}
+ parentSpanId={props.parentSpanId} />
{props.children}
>
);
@@ -88,6 +88,8 @@ function TimeToDisplay(props: {
* Starts a new span for the initial display.
*
* Returns current span if already exists in the currently active span.
+ *
+ * @deprecated Use `` component instead.
*/
export function startTimeToInitialDisplaySpan(
options?: Omit & {
@@ -132,6 +134,8 @@ export function startTimeToInitialDisplaySpan(
* Starts a new span for the full display.
*
* Returns current span if already exists in the currently active span.
+ *
+ * @deprecated Use `` component instead.
*/
export function startTimeToFullDisplaySpan(
options: Omit & {
@@ -196,24 +200,26 @@ export function startTimeToFullDisplaySpan(
return fullDisplaySpan;
}
-function onDrawNextFrame(event: { nativeEvent: RNSentryOnDrawNextFrameEvent }): void {
- logger.debug(`[TimeToDisplay] onDrawNextFrame: ${JSON.stringify(event.nativeEvent)}`);
- if (event.nativeEvent.type === 'fullDisplay') {
- return updateFullDisplaySpan(event.nativeEvent.newFrameTimestampInSeconds);
- }
- if (event.nativeEvent.type === 'initialDisplay') {
- return updateInitialDisplaySpan(event.nativeEvent.newFrameTimestampInSeconds);
- }
-}
-
-function updateInitialDisplaySpan(frameTimestampSeconds: number): void {
- const span = startTimeToInitialDisplaySpan();
+/**
+ *
+ */
+export function updateInitialDisplaySpan(
+ frameTimestampSeconds: number,
+ {
+ activeSpan = getActiveSpan(),
+ span = startTimeToInitialDisplaySpan(),
+ }: {
+ activeSpan?: Span;
+ /**
+ * Time to initial display span to update.
+ */
+ span?: Span;
+ } = {}): void {
if (!span) {
logger.warn(`[TimeToDisplay] No span found or created, possibly performance is disabled.`);
return;
}
- const activeSpan = getActiveSpan();
if (!activeSpan) {
logger.warn(`[TimeToDisplay] No active span found to attach ui.load.initial_display to.`);
return;
@@ -239,7 +245,7 @@ function updateInitialDisplaySpan(frameTimestampSeconds: number): void {
updateFullDisplaySpan(frameTimestampSeconds, span);
}
- setSpanDurationAsMeasurement('time_to_initial_display', span);
+ setSpanDurationAsMeasurementOnSpan('time_to_initial_display', span, activeSpan);
}
function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDisplaySpan?: Span): void {
@@ -284,3 +290,55 @@ function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDispl
setSpanDurationAsMeasurement('time_to_full_display', span);
}
+
+/**
+ * Creates a new TimeToFullDisplay component which triggers the full display recording every time the component is focused.
+ */
+export function createTimeToFullDisplay({
+ useFocusEffect,
+}: {
+ /**
+ * `@react-navigation/native` useFocusEffect hook.
+ */
+ useFocusEffect: (callback: () => void) => void
+}): React.ComponentType {
+ return createTimeToDisplay({ useFocusEffect, Component: TimeToFullDisplay });
+}
+
+/**
+ * Creates a new TimeToInitialDisplay component which triggers the initial display recording every time the component is focused.
+ */
+export function createTimeToInitialDisplay({
+ useFocusEffect,
+}: {
+ useFocusEffect: (callback: () => void) => void
+}): React.ComponentType {
+ return createTimeToDisplay({ useFocusEffect, Component: TimeToInitialDisplay });
+}
+
+function createTimeToDisplay({
+ useFocusEffect,
+ Component,
+}: {
+ /**
+ * `@react-navigation/native` useFocusEffect hook.
+ */
+ useFocusEffect: (callback: () => void) => void;
+ Component: typeof TimeToFullDisplay | typeof TimeToInitialDisplay;
+}): React.ComponentType {
+ const TimeToDisplayWrapper = (props: TimeToDisplayProps): React.ReactElement => {
+ const [focused, setFocused] = useState(false);
+
+ useFocusEffect(() => {
+ setFocused(true);
+ return () => {
+ setFocused(false);
+ };
+ });
+
+ return ;
+ };
+
+ TimeToDisplayWrapper.displayName = `TimeToDisplayWrapper`;
+ return TimeToDisplayWrapper;
+}
diff --git a/packages/core/src/js/tracing/timetodisplaynative.tsx b/packages/core/src/js/tracing/timetodisplaynative.tsx
index 8db6a30316..d549fe8b87 100644
--- a/packages/core/src/js/tracing/timetodisplaynative.tsx
+++ b/packages/core/src/js/tracing/timetodisplaynative.tsx
@@ -2,6 +2,7 @@ import * as React from 'react';
import type { HostComponent } from 'react-native';
import { UIManager, View } from 'react-native';
+import { isExpoGo } from '../utils/environment';
import { ReactNativeLibraries } from '../utils/rnlibraries';
import type { RNSentryOnDrawReporterProps } from './timetodisplaynative.types';
@@ -29,7 +30,7 @@ let RNSentryOnDrawReporter: HostComponent | typeof
*/
export const getRNSentryOnDrawReporter = (): typeof RNSentryOnDrawReporter => {
if (!RNSentryOnDrawReporter) {
- RNSentryOnDrawReporter = nativeComponentExists && ReactNativeLibraries.ReactNative?.requireNativeComponent
+ RNSentryOnDrawReporter = !isExpoGo() && nativeComponentExists && ReactNativeLibraries.ReactNative?.requireNativeComponent
? ReactNativeLibraries.ReactNative.requireNativeComponent(RNSentryOnDrawReporterClass)
: RNSentryOnDrawReporterNoop;
}
diff --git a/packages/core/src/js/tracing/timetodisplaynative.types.ts b/packages/core/src/js/tracing/timetodisplaynative.types.ts
index 85fbf5b4a2..ce6c90fe68 100644
--- a/packages/core/src/js/tracing/timetodisplaynative.types.ts
+++ b/packages/core/src/js/tracing/timetodisplaynative.types.ts
@@ -5,7 +5,7 @@ export interface RNSentryOnDrawNextFrameEvent {
export interface RNSentryOnDrawReporterProps {
children?: React.ReactNode;
- onDrawNextFrame: (event: { nativeEvent: RNSentryOnDrawNextFrameEvent }) => void;
initialDisplay?: boolean;
fullDisplay?: boolean;
+ parentSpanId?: string;
}
diff --git a/packages/core/src/js/utils/expoglobalobject.ts b/packages/core/src/js/utils/expoglobalobject.ts
index e1a39c63b0..c36afde03c 100644
--- a/packages/core/src/js/utils/expoglobalobject.ts
+++ b/packages/core/src/js/utils/expoglobalobject.ts
@@ -44,9 +44,31 @@ export interface ExpoDevice {
totalMemory?: number;
}
+/**
+ * Interface from the Expo SDK defined here
+ * (we are describing the native module
+ * the TS typing is only guideline)
+ *
+ * https://github.com/expo/expo/blob/8b7165ad2c6751c741f588c72dac50fb3a814dcc/packages/expo-updates/src/Updates.ts
+ */
+export interface ExpoUpdates {
+ isEnabled?: boolean;
+ updateId?: string | null;
+ channel?: string | null;
+ runtimeVersion?: string | null;
+ checkAutomatically?: string | null;
+ isEmergencyLaunch?: boolean;
+ emergencyLaunchReason?: string | null;
+ launchDuration?: number | null;
+ isEmbeddedLaunch?: boolean;
+ isUsingEmbeddedAssets?: boolean;
+ createdAt?: Date | null;
+}
+
export interface ExpoGlobalObject {
modules?: {
ExponentConstants?: ExpoConstants;
ExpoDevice?: ExpoDevice;
+ ExpoUpdates?: ExpoUpdates;
};
}
diff --git a/packages/core/src/js/utils/expomodules.ts b/packages/core/src/js/utils/expomodules.ts
index 349c454d9d..9f606a4f76 100644
--- a/packages/core/src/js/utils/expomodules.ts
+++ b/packages/core/src/js/utils/expomodules.ts
@@ -1,18 +1,23 @@
-import type { ExpoConstants, ExpoDevice } from './expoglobalobject';
+import type { ExpoConstants, ExpoDevice, ExpoUpdates } from './expoglobalobject';
import { RN_GLOBAL_OBJ } from './worldwide';
/**
* Returns the Expo Constants module if present
*/
export function getExpoConstants(): ExpoConstants | undefined {
- return (
- (RN_GLOBAL_OBJ.expo && RN_GLOBAL_OBJ.expo.modules && RN_GLOBAL_OBJ.expo.modules.ExponentConstants) || undefined
- );
+ return RN_GLOBAL_OBJ.expo?.modules?.ExponentConstants ?? undefined;
}
/**
* Returns the Expo Device module if present
*/
export function getExpoDevice(): ExpoDevice | undefined {
- return (RN_GLOBAL_OBJ.expo && RN_GLOBAL_OBJ.expo.modules && RN_GLOBAL_OBJ.expo.modules.ExpoDevice) || undefined;
+ return RN_GLOBAL_OBJ.expo?.modules?.ExpoDevice ?? undefined;
+}
+
+/**
+ * Returns the Expo Updates module if present
+ */
+export function getExpoUpdates(): ExpoUpdates | undefined {
+ return RN_GLOBAL_OBJ.expo?.modules?.ExpoUpdates ?? undefined;
}
diff --git a/packages/core/src/js/utils/rnlibraries.ts b/packages/core/src/js/utils/rnlibraries.ts
index b5fa610842..b05167c2ef 100644
--- a/packages/core/src/js/utils/rnlibraries.ts
+++ b/packages/core/src/js/utils/rnlibraries.ts
@@ -1,14 +1,23 @@
/* eslint-disable @typescript-eslint/no-var-requires */
-
-import { Platform, TurboModuleRegistry } from 'react-native';
+import { AppRegistry, Platform, TurboModuleRegistry } from 'react-native';
import type * as ReactNative from '../vendor/react-native';
import type { ReactNativeLibrariesInterface } from './rnlibrariesinterface';
-export const ReactNativeLibraries: Required = {
+const InternalReactNativeLibrariesInterface: Required = {
Devtools: {
parseErrorStack: (errorStack: string): Array => {
const parseErrorStack = require('react-native/Libraries/Core/Devtools/parseErrorStack');
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ if (parseErrorStack.default && typeof parseErrorStack.default === 'function') {
+ // Starting with react-native 0.79, the parseErrorStack is a default export
+ // https://github.com/facebook/react-native/commit/e5818d92a867dbfa5f60d176b847b1f2131cb6da
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ return parseErrorStack.default(errorStack);
+ }
+
+ // react-native 0.78 and below
return parseErrorStack(errorStack);
},
symbolicateStackTrace: (
@@ -16,10 +25,30 @@ export const ReactNativeLibraries: Required = {
extraData?: Record,
): Promise => {
const symbolicateStackTrace = require('react-native/Libraries/Core/Devtools/symbolicateStackTrace');
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ if (symbolicateStackTrace.default && typeof symbolicateStackTrace.default === 'function') {
+ // Starting with react-native 0.79, the symbolicateStackTrace is a default export
+ // https://github.com/facebook/react-native/commit/e5818d92a867dbfa5f60d176b847b1f2131cb6da
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ return symbolicateStackTrace.default(stack, extraData);
+ }
+
+ // react-native 0.78 and below
return symbolicateStackTrace(stack, extraData);
},
getDevServer: (): ReactNative.DevServerInfo => {
const getDevServer = require('react-native/Libraries/Core/Devtools/getDevServer');
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ if (getDevServer.default && typeof getDevServer.default === 'function') {
+ // Starting with react-native 0.79, the getDevServer is a default export
+ // https://github.com/facebook/react-native/commit/e5818d92a867dbfa5f60d176b847b1f2131cb6da
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ return getDevServer.default();
+ }
+
+ // react-native 0.78 and below
return getDevServer();
},
},
@@ -34,6 +63,7 @@ export const ReactNativeLibraries: Required = {
version: Platform.constants?.reactNativeVersion,
},
TurboModuleRegistry,
+ AppRegistry,
ReactNative: {
requireNativeComponent: (viewName: string): ReactNative.HostComponent => {
const { requireNativeComponent } = require('react-native');
@@ -41,3 +71,5 @@ export const ReactNativeLibraries: Required = {
},
},
};
+
+export const ReactNativeLibraries: ReactNativeLibrariesInterface = InternalReactNativeLibrariesInterface;
diff --git a/packages/core/src/js/utils/rnlibrariesinterface.ts b/packages/core/src/js/utils/rnlibrariesinterface.ts
index 0d485adefc..77e27ca729 100644
--- a/packages/core/src/js/utils/rnlibrariesinterface.ts
+++ b/packages/core/src/js/utils/rnlibrariesinterface.ts
@@ -25,6 +25,7 @@ export interface ReactNativeLibrariesInterface {
Promise?: typeof Promise;
ReactNativeVersion?: ReactNative.ReactNativeVersion;
TurboModuleRegistry?: ReactNative.TurboModuleRegistry;
+ AppRegistry?: ReactNative.AppRegistry;
ReactNative?: {
requireNativeComponent?: (viewName: string) => ReactNative.HostComponent;
};
diff --git a/packages/core/src/js/utils/worldwide.ts b/packages/core/src/js/utils/worldwide.ts
index 778e58edaf..6dd202e7c2 100644
--- a/packages/core/src/js/utils/worldwide.ts
+++ b/packages/core/src/js/utils/worldwide.ts
@@ -4,6 +4,11 @@ import type { ErrorUtils } from 'react-native/types';
import type { ReactNativeOptions } from '../options';
import type { ExpoGlobalObject } from './expoglobalobject';
+export interface HermesPromiseRejectionTrackingOptions {
+ allRejections: boolean;
+ onUnhandled: (id: string, error: unknown) => void;
+ onHandled: (id: string) => void;
+}
/** Internal Global object interface with common and Sentry specific properties */
export interface ReactNativeInternalGlobal extends InternalGlobal {
@@ -11,6 +16,8 @@ export interface ReactNativeInternalGlobal extends InternalGlobal {
__sentry_rn_v5_registered?: boolean;
HermesInternal?: {
getRuntimeProperties?: () => Record;
+ enablePromiseRejectionTracker?: (options: HermesPromiseRejectionTrackingOptions) => void;
+ hasPromise?: () => boolean;
};
Promise: unknown;
__turboModuleProxy: unknown;
diff --git a/packages/core/src/js/vendor/react-native/index.ts b/packages/core/src/js/vendor/react-native/index.ts
index 491a6f1f5d..418ff06bb9 100644
--- a/packages/core/src/js/vendor/react-native/index.ts
+++ b/packages/core/src/js/vendor/react-native/index.ts
@@ -82,3 +82,7 @@ export type ReactNativeVersion = {
prerelease?: string | null | undefined;
};
};
+
+export type AppRegistry = {
+ runApplication: (appKey: string, appParameters: any) => void;
+};
diff --git a/packages/core/src/js/vendor/react-navigation/types.ts b/packages/core/src/js/vendor/react-navigation/types.ts
new file mode 100644
index 0000000000..6319c4b0a5
--- /dev/null
+++ b/packages/core/src/js/vendor/react-navigation/types.ts
@@ -0,0 +1,50 @@
+// MIT License
+
+// Copyright (c) 2017 React Navigation Contributors
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+// https://github.com/react-navigation/react-navigation/blob/a656331009fcb534e0bef535e6df65ae07a228a4/packages/core/src/types.tsx#L777
+
+/**
+ * Event which fires when an action is dispatched.
+ * Only intended for debugging purposes, don't use it for app logic.
+ * This event will be emitted before state changes have been applied.
+ */
+export type UnsafeAction = {
+ data: {
+ /**
+ * The action object which was dispatched.
+ */
+ action: {
+ readonly type: string;
+ readonly payload?: object | undefined;
+ readonly source?: string | undefined;
+ readonly target?: string | undefined;
+ };
+ /**
+ * Whether the action was a no-op, i.e. resulted any state changes.
+ */
+ noop: boolean;
+ /**
+ * Stack trace of the action, this will only be available during development.
+ */
+ stack: string | undefined;
+ };
+};
diff --git a/packages/core/src/js/version.ts b/packages/core/src/js/version.ts
index daf16ff05e..82d109f96f 100644
--- a/packages/core/src/js/version.ts
+++ b/packages/core/src/js/version.ts
@@ -1,3 +1,3 @@
export const SDK_PACKAGE_NAME = 'npm:@sentry/react-native';
export const SDK_NAME = 'sentry.javascript.react-native';
-export const SDK_VERSION = '6.8.0';
+export const SDK_VERSION = '6.15.1';
diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts
index 9b37a9b87b..fdd04e8ac8 100644
--- a/packages/core/src/js/wrapper.ts
+++ b/packages/core/src/js/wrapper.ts
@@ -122,6 +122,11 @@ interface SentryNativeWrapper {
getNewScreenTimeToDisplay(): Promise;
getDataFromUri(uri: string): Promise;
+ popTimeToDisplayFor(key: string): Promise;
+
+ setActiveSpanId(spanId: string): void;
+
+ encodeToBase64(data: Uint8Array): Promise;
}
const EOL = utf8ToBytes('\n');
@@ -692,7 +697,7 @@ export const NATIVE: SentryNativeWrapper = {
return null;
}
- const result = RNSentry.crashedLastRun();
+ const result = await RNSentry.crashedLastRun();
return typeof result === 'boolean' ? result : null;
},
@@ -717,6 +722,47 @@ export const NATIVE: SentryNativeWrapper = {
}
},
+ popTimeToDisplayFor(key: string): Promise {
+ if (!this.enableNative || !this._isModuleLoaded(RNSentry)) {
+ return Promise.resolve(null);
+ }
+
+ try {
+ return RNSentry.popTimeToDisplayFor(key);
+ } catch (error) {
+ logger.error('Error:', error);
+ return null;
+ }
+ },
+
+ setActiveSpanId(spanId): void {
+ if (!this.enableNative || !this._isModuleLoaded(RNSentry)) {
+ return undefined;
+ }
+
+ try {
+ RNSentry.setActiveSpanId(spanId);
+ } catch (error) {
+ logger.error('Error:', error);
+ return undefined;
+ }
+ },
+
+ async encodeToBase64(data: Uint8Array): Promise {
+ if (!this.enableNative || !this._isModuleLoaded(RNSentry)) {
+ return Promise.resolve(null);
+ }
+
+ try {
+ const byteArray = Array.from(data);
+ const base64 = await RNSentry.encodeToBase64(byteArray);
+ return base64 || null;
+ } catch (error) {
+ logger.error('Error:', error);
+ return Promise.resolve(null);
+ }
+ },
+
/**
* Gets the event from envelopeItem and applies the level filter to the selected event.
* @param data An envelope item containing the event.
@@ -804,3 +850,12 @@ export const NATIVE: SentryNativeWrapper = {
nativeIsReady: false,
platform: Platform.OS,
};
+
+/**
+ * Fethces the data from the given uri in Uint8Array format.
+ * @param uri string
+ * @returns Uint8Array | null
+ */
+export async function getDataFromUri(uri: string): Promise {
+ return NATIVE.getDataFromUri(uri);
+}
diff --git a/packages/core/test/clientAfterInit.test.ts b/packages/core/test/clientAfterInit.test.ts
new file mode 100644
index 0000000000..227d4d09bb
--- /dev/null
+++ b/packages/core/test/clientAfterInit.test.ts
@@ -0,0 +1,78 @@
+import { ReactNativeClient } from '../src/js';
+import type { ReactNativeClientOptions } from '../src/js/options';
+import { NATIVE } from './mockWrapper';
+
+jest.useFakeTimers({ advanceTimers: true });
+
+jest.mock('../src/js/wrapper', () => jest.requireActual('./mockWrapper'));
+
+describe('ReactNativeClient emits `afterInit` event', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('emits `afterInit` event when native is enabled', async () => {
+ const client = setupReactNativeClient({
+ enableNative: true,
+ });
+
+ const emitSpy = jest.spyOn(client, 'emit');
+ client.init();
+
+ await jest.runOnlyPendingTimersAsync();
+
+ expect(emitSpy).toHaveBeenCalledWith('afterInit');
+ });
+
+ test('emits `afterInit` event when native is disabled', async () => {
+ const client = setupReactNativeClient({
+ enableNative: false,
+ });
+
+ const emitSpy = jest.spyOn(client, 'emit');
+ client.init();
+
+ await jest.runOnlyPendingTimersAsync();
+ expect(emitSpy).toHaveBeenCalledWith('afterInit');
+ });
+
+ test('emits `afterInit` event when native init is rejected', async () => {
+ NATIVE.initNativeSdk = jest.fn().mockRejectedValue(new Error('Test Native Init Rejected'));
+
+ const client = setupReactNativeClient({
+ enableNative: false,
+ });
+
+ const emitSpy = jest.spyOn(client, 'emit');
+ client.init();
+
+ await jest.runOnlyPendingTimersAsync();
+ expect(emitSpy).toHaveBeenCalledWith('afterInit');
+ });
+});
+
+function setupReactNativeClient(options: Partial = {}): ReactNativeClient {
+ return new ReactNativeClient({
+ ...DEFAULT_OPTIONS,
+ ...options,
+ });
+}
+
+const EXAMPLE_DSN = 'https://6890c2f6677340daa4804f8194804ea2@o19635.ingest.sentry.io/148053';
+
+const DEFAULT_OPTIONS: ReactNativeClientOptions = {
+ dsn: EXAMPLE_DSN,
+ enableNative: true,
+ enableNativeCrashHandling: true,
+ enableNativeNagger: true,
+ autoInitializeNativeSdk: true,
+ enableAutoPerformanceTracing: true,
+ enableWatchdogTerminationTracking: true,
+ patchGlobalPromise: true,
+ integrations: [],
+ transport: () => ({
+ send: jest.fn(),
+ flush: jest.fn(),
+ }),
+ stackParser: jest.fn().mockReturnValue([]),
+};
diff --git a/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts b/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts
index 0ed9a95551..be48ecea14 100644
--- a/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts
+++ b/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts
@@ -138,13 +138,13 @@ describe('withSentryAndroidGradlePlugin', () => {
expect(modifiedGradle.modResults.contents).toContain('apply plugin: "io.sentry.android.gradle"');
expect(modifiedGradle.modResults.contents).toContain(`
sentry {
- autoUploadProguardMapping = true
+ autoUploadProguardMapping = shouldSentryAutoUpload()
includeProguardMapping = true
dexguardEnabled = false
- uploadNativeSymbols = true
- autoUploadNativeSymbols = true
+ uploadNativeSymbols = shouldSentryAutoUpload()
+ autoUploadNativeSymbols = shouldSentryAutoUpload()
includeNativeSources = false
- includeSourceContext = true
+ includeSourceContext = shouldSentryAutoUpload()
tracingInstrumentation {
enabled = false
}
diff --git a/packages/core/test/feedback/FeedbackButton.test.tsx b/packages/core/test/feedback/FeedbackButton.test.tsx
new file mode 100644
index 0000000000..579bccc7ca
--- /dev/null
+++ b/packages/core/test/feedback/FeedbackButton.test.tsx
@@ -0,0 +1,56 @@
+import { fireEvent, render, waitFor } from '@testing-library/react-native';
+import * as React from 'react';
+
+import { FeedbackButton } from '../../src/js/feedback/FeedbackButton';
+import type { FeedbackButtonProps, FeedbackButtonStyles } from '../../src/js/feedback/FeedbackWidget.types';
+import { showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager';
+
+jest.mock('../../src/js/feedback/FeedbackWidgetManager', () => ({
+ ...jest.requireActual('../../src/js/feedback/FeedbackWidgetManager'),
+ showFeedbackWidget: jest.fn(),
+}));
+
+const customTextProps: FeedbackButtonProps = {
+ triggerLabel: 'Give Feedback',
+};
+
+export const customStyles: FeedbackButtonStyles = {
+ triggerButton: {
+ backgroundColor: '#ffffff',
+ },
+ triggerText: {
+ color: '#ff0000',
+ },
+};
+
+describe('FeedbackButton', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('matches the snapshot with default configuration', () => {
+ const { toJSON } = render();
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('matches the snapshot with custom texts', () => {
+ const { toJSON } = render();
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('matches the snapshot with custom styles', () => {
+ const customStyleProps = {styles: customStyles};
+ const { toJSON } = render();
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('shows the feedback widget when pressed', async () => {
+ const { getByText } = render();
+
+ fireEvent.press(getByText(customTextProps.triggerLabel));
+
+ await waitFor(() => {
+ expect(showFeedbackWidget).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/core/test/feedback/FeedbackWidgetManager.test.tsx b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx
index ee317d2650..21e10bff1e 100644
--- a/packages/core/test/feedback/FeedbackWidgetManager.test.tsx
+++ b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx
@@ -1,17 +1,19 @@
import { getClient, logger, setCurrentClient } from '@sentry/core';
import { render } from '@testing-library/react-native';
import * as React from 'react';
-import { Text } from 'react-native';
+import { Appearance, Text } from 'react-native';
import { defaultConfiguration } from '../../src/js/feedback/defaults';
-import { FeedbackWidgetProvider, resetFeedbackWidgetManager, showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager';
+import { hideFeedbackButton,resetFeedbackButtonManager, resetFeedbackWidgetManager, showFeedbackButton, showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager';
+import { FeedbackWidgetProvider } from '../../src/js/feedback/FeedbackWidgetProvider';
import { feedbackIntegration } from '../../src/js/feedback/integration';
-import { AUTO_INJECT_FEEDBACK_INTEGRATION_NAME } from '../../src/js/feedback/lazy';
+import { AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME,AUTO_INJECT_FEEDBACK_INTEGRATION_NAME } from '../../src/js/feedback/lazy';
import { isModalSupported } from '../../src/js/feedback/utils';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
jest.mock('../../src/js/feedback/utils', () => ({
isModalSupported: jest.fn(),
+ isNativeDriverSupportedForColorAnimations: jest.fn().mockReturnValue(true),
}));
const consoleWarnSpy = jest.spyOn(console, 'warn');
@@ -114,7 +116,7 @@ describe('FeedbackWidgetManager', () => {
showFeedbackWidget();
- expect(consoleWarnSpy).toHaveBeenLastCalledWith('[Sentry] FeedbackWidget requires `Sentry.wrap(RootComponent)` to be called before `showFeedbackWidget()`.');
+ expect(consoleWarnSpy).toHaveBeenLastCalledWith(`[Sentry] FeedbackWidget requires 'Sentry.wrap(RootComponent)' to be called before 'showFeedbackWidget()'.`);
});
it('showFeedbackWidget does not warn about missing feedback provider when FeedbackWidgetProvider is used', () => {
@@ -130,7 +132,7 @@ describe('FeedbackWidgetManager', () => {
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
-
+
it('showFeedbackWidget adds the feedbackIntegration to the client', () => {
mockedIsModalSupported.mockReturnValue(true);
@@ -139,3 +141,363 @@ describe('FeedbackWidgetManager', () => {
expect(getClient().getIntegrationByName(AUTO_INJECT_FEEDBACK_INTEGRATION_NAME)).toBeDefined();
});
});
+
+describe('FeedbackButtonManager', () => {
+ let listener: (preferences: Appearance.AppearancePreferences) => void;
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ beforeEach(() => {
+ const client = new TestClient(getDefaultTestClientOptions());
+ setCurrentClient(client);
+ client.init();
+ consoleWarnSpy.mockReset();
+ resetFeedbackButtonManager();
+
+ jest.spyOn(Appearance, 'addChangeListener').mockImplementation((cb) => {
+ listener = cb;
+ return { remove: jest.fn() };
+ });
+ });
+
+ it('showFeedbackButton displays the button when FeedbackWidgetProvider is used', () => {
+ const { getByText } = render(
+
+ App Components
+
+ );
+
+ showFeedbackButton();
+
+ expect(getByText('Report a Bug')).toBeTruthy();
+ });
+
+ it('hideFeedbackButton hides the button', () => {
+ const { queryByText } = render(
+
+ App Components
+
+ );
+
+ showFeedbackButton();
+ hideFeedbackButton();
+
+ expect(queryByText('Report a Bug')).toBeNull();
+ });
+
+ it('showFeedbackButton does not throw an error when FeedbackWidgetProvider is not used', () => {
+ expect(() => {
+ showFeedbackButton();
+ }).not.toThrow();
+ });
+
+ it('showFeedbackButton warns about missing feedback provider', () => {
+ showFeedbackButton();
+
+ expect(consoleWarnSpy).toHaveBeenLastCalledWith(`[Sentry] FeedbackButton requires 'Sentry.wrap(RootComponent)' to be called before 'showFeedbackButton()'.`);
+ });
+
+ it('showFeedbackButton does not warn about missing feedback provider when FeedbackWidgetProvider is used', () => {
+ render(
+
+ App Components
+
+ );
+
+ showFeedbackButton();
+
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
+ });
+
+ it('showFeedbackButton adds the feedbackIntegration to the client', () => {
+ showFeedbackButton();
+
+ expect(getClient().getIntegrationByName(AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME)).toBeDefined();
+ });
+
+ it('the Feedback Widget matches the snapshot with default configuration and system light theme', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+ const { toJSON } = render(
+
+ App Components
+
+ );
+
+ jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light');
+
+ showFeedbackWidget();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('the Feedback Widget matches the snapshot with default configuration and system dark theme', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+ const { toJSON } = render(
+
+ App Components
+
+ );
+
+ jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark');
+
+ showFeedbackWidget();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('the Feedback Widget matches the snapshot with default configuration and dynamically changed theme', () => {
+ const component = (
+
+ App Components
+
+ );
+
+ mockedIsModalSupported.mockReturnValue(true);
+ const { toJSON } = render(component);
+
+ jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light');
+
+ showFeedbackWidget();
+
+ jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark');
+ listener({ colorScheme: 'dark' });
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('the Feedback Widget matches the snapshot with custom light theme', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+ const { toJSON } = render(
+
+ App Components
+
+ );
+
+ const integration = feedbackIntegration({
+ colorScheme: 'light',
+ themeLight: {
+ foreground: '#ff0000',
+ background: '#00ff00',
+ },
+ });
+ getClient()?.addIntegration(integration);
+
+ showFeedbackWidget();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('the Feedback Widget matches the snapshot with custom dark theme', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+ const { toJSON } = render(
+
+ App Components
+
+ );
+
+ const integration = feedbackIntegration({
+ colorScheme: 'dark',
+ themeDark: {
+ foreground: '#00ff00',
+ background: '#ff0000',
+ },
+ });
+ getClient()?.addIntegration(integration);
+
+ showFeedbackWidget();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('the Feedback Widget matches the snapshot with system light custom theme', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+ const { toJSON } = render(
+
+ App Components
+
+ );
+
+ const integration = feedbackIntegration({
+ colorScheme: 'system',
+ themeLight: {
+ foreground: '#ff0000',
+ background: '#00ff00',
+ },
+ });
+ getClient()?.addIntegration(integration);
+
+ jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light');
+
+ showFeedbackWidget();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('the Feedback Widget matches the snapshot with system dark custom theme', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+ const { toJSON } = render(
+
+ App Components
+
+ );
+
+ const integration = feedbackIntegration({
+ colorScheme: 'system',
+ themeDark: {
+ foreground: '#00ff00',
+ background: '#ff0000',
+ },
+ });
+ getClient()?.addIntegration(integration);
+
+ jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark');
+
+ showFeedbackWidget();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('the Feedback Button matches the snapshot with default configuration and system light theme', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+ const { toJSON } = render(
+
+ App Components
+
+ );
+
+ jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light');
+
+ showFeedbackButton();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('the Feedback Button matches the snapshot with default configuration and system dark theme', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+ const { toJSON } = render(
+
+ App Components
+
+ );
+
+ jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark');
+
+ showFeedbackButton();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('the Feedback Button matches the snapshot with default configuration and dynamically changed theme', () => {
+ const component = (
+
+ App Components
+
+ );
+
+ mockedIsModalSupported.mockReturnValue(true);
+ const { toJSON } = render(component);
+
+ jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light');
+
+ showFeedbackButton();
+
+ jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark');
+ listener({ colorScheme: 'dark' });
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('the Feedback Button matches the snapshot with custom light theme', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+ const { toJSON } = render(
+
+ App Components
+
+ );
+
+ const integration = feedbackIntegration({
+ colorScheme: 'light',
+ themeLight: {
+ foreground: '#ff0000',
+ background: '#00ff00',
+ },
+ });
+ getClient()?.addIntegration(integration);
+
+ showFeedbackButton();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('the Feedback Button matches the snapshot with custom dark theme', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+ const { toJSON } = render(
+
+ App Components
+
+ );
+
+ const integration = feedbackIntegration({
+ colorScheme: 'dark',
+ themeDark: {
+ foreground: '#00ff00',
+ background: '#ff0000',
+ },
+ });
+ getClient()?.addIntegration(integration);
+
+ showFeedbackButton();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('the Feedback Button matches the snapshot with system light custom theme', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+ const { toJSON } = render(
+
+ App Components
+
+ );
+
+ const integration = feedbackIntegration({
+ colorScheme: 'system',
+ themeLight: {
+ foreground: '#ff0000',
+ background: '#00ff00',
+ },
+ });
+ getClient()?.addIntegration(integration);
+
+ jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light');
+
+ showFeedbackButton();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('the Feedback Button matches the snapshot with system dark custom theme', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+ const { toJSON } = render(
+
+ App Components
+
+ );
+
+ const integration = feedbackIntegration({
+ colorScheme: 'system',
+ themeDark: {
+ foreground: '#00ff00',
+ background: '#ff0000',
+ },
+ });
+ getClient()?.addIntegration(integration);
+
+ jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark');
+
+ showFeedbackButton();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/packages/core/test/feedback/ScreenshotButton.test.tsx b/packages/core/test/feedback/ScreenshotButton.test.tsx
new file mode 100644
index 0000000000..2419860d1c
--- /dev/null
+++ b/packages/core/test/feedback/ScreenshotButton.test.tsx
@@ -0,0 +1,221 @@
+import { getClient, setCurrentClient } from '@sentry/core';
+import { fireEvent, render, waitFor } from '@testing-library/react-native';
+import * as React from 'react';
+import { Alert, Text } from 'react-native';
+
+import { FeedbackWidget } from '../../src/js/feedback/FeedbackWidget';
+import type { ScreenshotButtonProps, ScreenshotButtonStyles } from '../../src/js/feedback/FeedbackWidget.types';
+import { resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager, showFeedbackButton } from '../../src/js/feedback/FeedbackWidgetManager';
+import { FeedbackWidgetProvider } from '../../src/js/feedback/FeedbackWidgetProvider';
+import { feedbackIntegration } from '../../src/js/feedback/integration';
+import { getCapturedScreenshot, ScreenshotButton } from '../../src/js/feedback/ScreenshotButton';
+import type { Screenshot } from '../../src/js/wrapper';
+import { NATIVE } from '../../src/js/wrapper';
+import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
+
+jest.mock('../../src/js/wrapper', () => ({
+ NATIVE: {
+ captureScreenshot: jest.fn(),
+ encodeToBase64: jest.fn(),
+ },
+}));
+
+jest.spyOn(Alert, 'alert');
+
+const mockScreenshot: Screenshot = {
+ filename: 'test-screenshot.png',
+ contentType: 'image/png',
+ data: new Uint8Array([1, 2, 3]),
+};
+
+const mockBase64Image = 'mockBase64ImageString';
+
+const mockCaptureScreenshot = NATIVE.captureScreenshot as jest.Mock;
+const mockEncodeToBase64 = NATIVE.encodeToBase64 as jest.Mock;
+
+describe('ScreenshotButton', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ FeedbackWidget.reset();
+ getCapturedScreenshot(); // cleans up stored screenshot if any
+ resetFeedbackWidgetManager();
+ resetFeedbackButtonManager();
+ resetScreenshotButtonManager();
+ const client = new TestClient(getDefaultTestClientOptions());
+ setCurrentClient(client);
+ client.init();
+ });
+
+ it('matches the snapshot with default configuration', () => {
+ const { toJSON } = render();
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('matches the snapshot with custom texts', () => {
+ const defaultProps: ScreenshotButtonProps = {
+ triggerLabel: 'Take Screenshot',
+ };
+ const { toJSON } = render();
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('matches the snapshot with custom styles', () => {
+ const customStyles: ScreenshotButtonStyles = {
+ triggerButton: {
+ backgroundColor: '#ffffff',
+ },
+ triggerText: {
+ color: '#ff0000',
+ },
+ };
+ const customStyleProps = {styles: customStyles};
+ const { toJSON } = render();
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('the take screenshot button is visible in the feedback widget when enabled', async () => {
+ const { getByText } = render(
+
+ App Components
+
+ );
+
+ const integration = feedbackIntegration({
+ enableTakeScreenshot: true,
+ });
+ getClient()?.addIntegration(integration);
+
+ showFeedbackButton();
+
+ fireEvent.press(getByText('Report a Bug'));
+
+ const takeScreenshotButton = getByText('Take a screenshot');
+ expect(takeScreenshotButton).toBeTruthy();
+ });
+
+
+ it('the capture screenshot button is shown when tapping the Take a screenshot button in the feedback widget', async () => {
+ const { getByText } = render(
+
+ App Components
+
+ );
+
+ const integration = feedbackIntegration({
+ enableTakeScreenshot: true,
+ });
+ getClient()?.addIntegration(integration);
+
+ showFeedbackButton();
+
+ fireEvent.press(getByText('Report a Bug'));
+ fireEvent.press(getByText('Take a screenshot'));
+
+ const captureButton = getByText('Take Screenshot');
+ expect(captureButton).toBeTruthy();
+ });
+
+ it('a screenshot is captured when tapping the Take Screenshot button', async () => {
+ mockCaptureScreenshot.mockResolvedValue([mockScreenshot]);
+ mockEncodeToBase64.mockResolvedValue(mockBase64Image);
+
+ const { getByText } = render(
+
+ App Components
+
+ );
+
+ const integration = feedbackIntegration({
+ enableTakeScreenshot: true,
+ });
+ getClient()?.addIntegration(integration);
+
+ showFeedbackButton();
+
+ fireEvent.press(getByText('Report a Bug'));
+ fireEvent.press(getByText('Take a screenshot'));
+ fireEvent.press(getByText('Take Screenshot'));
+
+ await waitFor(() => {
+ expect(mockCaptureScreenshot).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(mockEncodeToBase64).toHaveBeenCalled();
+ });
+ });
+
+ it('the feedback widget ui is updated when a screenshot is captured', async () => {
+ mockCaptureScreenshot.mockResolvedValue([mockScreenshot]);
+ mockEncodeToBase64.mockResolvedValue(mockBase64Image);
+
+ const { getByText, queryByText } = render(
+
+ App Components
+
+ );
+
+ const integration = feedbackIntegration({
+ enableTakeScreenshot: true,
+ });
+ getClient()?.addIntegration(integration);
+
+ showFeedbackButton();
+
+ fireEvent.press(getByText('Report a Bug'));
+ fireEvent.press(getByText('Take a screenshot'));
+ fireEvent.press(getByText('Take Screenshot'));
+
+ await waitFor(() => {
+ expect(mockCaptureScreenshot).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(mockEncodeToBase64).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ const captureButton = queryByText('Take Screenshot');
+ expect(captureButton).toBeNull();
+ });
+
+ await waitFor(() => {
+ const takeScreenshotButtonAfterCapture = queryByText('Take a screenshot');
+ expect(takeScreenshotButtonAfterCapture).toBeNull();
+ });
+
+ await waitFor(() => {
+ const removeScreenshotButtonAfterCapture = queryByText('Remove screenshot');
+ expect(removeScreenshotButtonAfterCapture).toBeTruthy();
+ });
+ });
+
+ it('when the capture fails an error message is shown', async () => {
+ mockCaptureScreenshot.mockResolvedValue([]);
+
+ const { getByText } = render(
+
+ App Components
+
+ );
+
+ const integration = feedbackIntegration({
+ enableTakeScreenshot: true,
+ });
+ getClient()?.addIntegration(integration);
+
+ showFeedbackButton();
+
+ fireEvent.press(getByText('Report a Bug'));
+ fireEvent.press(getByText('Take a screenshot'));
+ fireEvent.press(getByText('Take Screenshot'));
+
+ await waitFor(() => {
+ expect(mockCaptureScreenshot).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith('Error', 'Error capturing screenshot. Please try again.');
+ });
+ });
+});
diff --git a/packages/core/test/feedback/__snapshots__/FeedbackButton.test.tsx.snap b/packages/core/test/feedback/__snapshots__/FeedbackButton.test.tsx.snap
new file mode 100644
index 0000000000..3115f5ccd9
--- /dev/null
+++ b/packages/core/test/feedback/__snapshots__/FeedbackButton.test.tsx.snap
@@ -0,0 +1,256 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FeedbackButton matches the snapshot with custom styles 1`] = `
+
+
+
+ Report a Bug
+
+
+`;
+
+exports[`FeedbackButton matches the snapshot with custom texts 1`] = `
+
+
+
+ Give Feedback
+
+
+`;
+
+exports[`FeedbackButton matches the snapshot with default configuration 1`] = `
+
+
+
+ Report a Bug
+
+
+`;
diff --git a/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap b/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap
index 9f71d72ceb..a3841f1597 100644
--- a/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap
+++ b/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap
@@ -2,6 +2,7 @@
exports[`FeedbackWidget matches the snapshot with custom styles 1`] = `
Report a Bug
@@ -53,6 +55,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles 1`] = `
style={
{
"height": 40,
+ "tintColor": "rgba(54, 45, 89, 1)",
"width": 40,
}
}
@@ -80,6 +83,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles 1`] = `
"height": 50,
}
}
+ testID="sentry-feedback-name-input"
value="Test User"
/>
Send Bug Report
@@ -234,6 +241,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles 1`] = `
exports[`FeedbackWidget matches the snapshot with custom styles and screenshot button 1`] = `
Report a Bug
@@ -285,6 +294,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles and screenshot b
style={
{
"height": 40,
+ "tintColor": "rgba(54, 45, 89, 1)",
"width": 40,
}
}
@@ -312,6 +322,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles and screenshot b
"height": 50,
}
}
+ testID="sentry-feedback-name-input"
value="Test User"
/>
Send Bug Report
@@ -523,6 +537,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles and screenshot b
exports[`FeedbackWidget matches the snapshot with custom texts 1`] = `
Feedback Form
@@ -580,6 +596,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts 1`] = `
style={
{
"height": 40,
+ "tintColor": "rgba(54, 45, 89, 1)",
"width": 40,
}
}
@@ -612,6 +629,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts 1`] = `
"paddingHorizontal": 10,
}
}
+ testID="sentry-feedback-name-input"
value="Test User"
/>
Submit Button Label
@@ -786,6 +807,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts 1`] = `
exports[`FeedbackWidget matches the snapshot with custom texts and screenshot button 1`] = `
Feedback Form
@@ -843,6 +866,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts and screenshot bu
style={
{
"height": 40,
+ "tintColor": "rgba(54, 45, 89, 1)",
"width": 40,
}
}
@@ -875,6 +899,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts and screenshot bu
"paddingHorizontal": 10,
}
}
+ testID="sentry-feedback-name-input"
value="Test User"
/>
Submit Button Label
@@ -1112,6 +1140,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts and screenshot bu
exports[`FeedbackWidget matches the snapshot with default configuration 1`] = `
Report a Bug
@@ -1169,6 +1199,7 @@ exports[`FeedbackWidget matches the snapshot with default configuration 1`] = `
style={
{
"height": 40,
+ "tintColor": "rgba(54, 45, 89, 1)",
"width": 40,
}
}
@@ -1201,6 +1232,7 @@ exports[`FeedbackWidget matches the snapshot with default configuration 1`] = `
"paddingHorizontal": 10,
}
}
+ testID="sentry-feedback-name-input"
value="Test User"
/>
Send Bug Report
@@ -1375,6 +1410,7 @@ exports[`FeedbackWidget matches the snapshot with default configuration 1`] = `
exports[`FeedbackWidget matches the snapshot with default configuration and screenshot button 1`] = `
Report a Bug
@@ -1432,6 +1469,7 @@ exports[`FeedbackWidget matches the snapshot with default configuration and scre
style={
{
"height": 40,
+ "tintColor": "rgba(54, 45, 89, 1)",
"width": 40,
}
}
@@ -1464,6 +1502,7 @@ exports[`FeedbackWidget matches the snapshot with default configuration and scre
"paddingHorizontal": 10,
}
}
+ testID="sentry-feedback-name-input"
value="Test User"
/>
Send Bug Report
diff --git a/packages/core/test/feedback/__snapshots__/FeedbackWidgetManager.test.tsx.snap b/packages/core/test/feedback/__snapshots__/FeedbackWidgetManager.test.tsx.snap
new file mode 100644
index 0000000000..521573f3ad
--- /dev/null
+++ b/packages/core/test/feedback/__snapshots__/FeedbackWidgetManager.test.tsx.snap
@@ -0,0 +1,3102 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FeedbackButtonManager the Feedback Button matches the snapshot with custom dark theme 1`] = `
+[
+
+ App Components
+ ,
+
+
+
+ Report a Bug
+
+ ,
+]
+`;
+
+exports[`FeedbackButtonManager the Feedback Button matches the snapshot with custom light theme 1`] = `
+[
+
+ App Components
+ ,
+
+
+
+ Report a Bug
+
+ ,
+]
+`;
+
+exports[`FeedbackButtonManager the Feedback Button matches the snapshot with default configuration and dynamically changed theme 1`] = `
+[
+
+ App Components
+ ,
+
+
+
+ Report a Bug
+
+ ,
+]
+`;
+
+exports[`FeedbackButtonManager the Feedback Button matches the snapshot with default configuration and system dark theme 1`] = `
+[
+
+ App Components
+ ,
+
+
+
+ Report a Bug
+
+ ,
+]
+`;
+
+exports[`FeedbackButtonManager the Feedback Button matches the snapshot with default configuration and system light theme 1`] = `
+[
+
+ App Components
+ ,
+
+
+
+ Report a Bug
+
+ ,
+]
+`;
+
+exports[`FeedbackButtonManager the Feedback Button matches the snapshot with system dark custom theme 1`] = `
+[
+
+ App Components
+ ,
+
+
+
+ Report a Bug
+
+ ,
+]
+`;
+
+exports[`FeedbackButtonManager the Feedback Button matches the snapshot with system light custom theme 1`] = `
+[
+
+ App Components
+ ,
+
+
+
+ Report a Bug
+
+ ,
+]
+`;
+
+exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with custom dark theme 1`] = `
+[
+
+ App Components
+ ,
+
+
+
+
+
+
+
+
+
+ Report a Bug
+
+
+
+
+ Name
+
+
+
+ Email
+
+
+
+ Description
+ (required)
+
+
+
+
+ Send Bug Report
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+ ,
+]
+`;
+
+exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with custom light theme 1`] = `
+[
+
+ App Components
+ ,
+
+
+
+
+
+
+
+
+
+ Report a Bug
+
+
+
+
+ Name
+
+
+
+ Email
+
+
+
+ Description
+ (required)
+
+
+
+
+ Send Bug Report
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+ ,
+]
+`;
+
+exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with default configuration and dynamically changed theme 1`] = `
+[
+
+ App Components
+ ,
+
+
+
+
+
+
+
+
+
+ Report a Bug
+
+
+
+
+ Name
+
+
+
+ Email
+
+
+
+ Description
+ (required)
+
+
+
+
+ Send Bug Report
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+ ,
+]
+`;
+
+exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with default configuration and system dark theme 1`] = `
+[
+
+ App Components
+ ,
+
+
+
+
+
+
+
+
+
+ Report a Bug
+
+
+
+
+ Name
+
+
+
+ Email
+
+
+
+ Description
+ (required)
+
+
+
+
+ Send Bug Report
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+ ,
+]
+`;
+
+exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with default configuration and system light theme 1`] = `
+[
+
+ App Components
+ ,
+
+
+
+
+
+
+
+
+
+ Report a Bug
+
+
+
+
+ Name
+
+
+
+ Email
+
+
+
+ Description
+ (required)
+
+
+
+
+ Send Bug Report
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+ ,
+]
+`;
+
+exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with system dark custom theme 1`] = `
+[
+
+ App Components
+ ,
+
+
+
+
+
+
+
+
+
+ Report a Bug
+
+
+
+
+ Name
+
+
+
+ Email
+
+
+
+ Description
+ (required)
+
+
+
+
+ Send Bug Report
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+ ,
+]
+`;
+
+exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with system light custom theme 1`] = `
+[
+
+ App Components
+ ,
+
+
+
+
+
+
+
+
+
+ Report a Bug
+
+
+
+
+ Name
+
+
+
+ Email
+
+
+
+ Description
+ (required)
+
+
+
+
+ Send Bug Report
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+ ,
+]
+`;
diff --git a/packages/core/test/feedback/__snapshots__/ScreenshotButton.test.tsx.snap b/packages/core/test/feedback/__snapshots__/ScreenshotButton.test.tsx.snap
new file mode 100644
index 0000000000..2cd6455502
--- /dev/null
+++ b/packages/core/test/feedback/__snapshots__/ScreenshotButton.test.tsx.snap
@@ -0,0 +1,253 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ScreenshotButton matches the snapshot with custom styles 1`] = `
+
+
+
+ Take Screenshot
+
+
+`;
+
+exports[`ScreenshotButton matches the snapshot with custom texts 1`] = `
+
+
+
+ Take Screenshot
+
+
+`;
+
+exports[`ScreenshotButton matches the snapshot with default configuration 1`] = `
+
+
+
+ Take Screenshot
+
+
+`;
diff --git a/packages/core/test/integrations/appRegistry.test.ts b/packages/core/test/integrations/appRegistry.test.ts
new file mode 100644
index 0000000000..535616c378
--- /dev/null
+++ b/packages/core/test/integrations/appRegistry.test.ts
@@ -0,0 +1,80 @@
+import { getOriginalFunction } from '@sentry/core';
+
+import { appRegistryIntegration } from '../../src/js/integrations/appRegistry';
+import * as Environment from '../../src/js/utils/environment';
+import { ReactNativeLibraries } from '../../src/js/utils/rnlibraries';
+
+const originalAppRegistry = ReactNativeLibraries.AppRegistry;
+const originalRunApplication = ReactNativeLibraries.AppRegistry.runApplication;
+
+describe('AppRegistry Integration', () => {
+ let mockedRunApplication: jest.SpyInstance;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockedRunApplication = jest.spyOn(ReactNativeLibraries.AppRegistry, 'runApplication').mockImplementation(jest.fn());
+ });
+
+ afterEach(() => {
+ ReactNativeLibraries.AppRegistry = originalAppRegistry;
+ ReactNativeLibraries.AppRegistry.runApplication = originalRunApplication;
+ });
+
+ it('does not patch app registry on init before setup', async () => {
+ appRegistryIntegration();
+
+ expect(getOriginalFunction(ReactNativeLibraries.AppRegistry.runApplication)).toBeUndefined();
+ expect(ReactNativeLibraries.AppRegistry.runApplication).toBe(mockedRunApplication);
+ });
+
+ it('patches app registry on init after setup', async () => {
+ appRegistryIntegration().setupOnce();
+
+ expect(getOriginalFunction(ReactNativeLibraries.AppRegistry.runApplication)).toBeDefined();
+ expect(ReactNativeLibraries.AppRegistry.runApplication).not.toBe(mockedRunApplication);
+ });
+
+ it('executes callbacks when runApplication is called', async () => {
+ const firstMockedCallback = jest.fn();
+ const secondMockedCallback = jest.fn();
+ const integration = appRegistryIntegration();
+
+ integration.setupOnce();
+ integration.onRunApplication(firstMockedCallback);
+ integration.onRunApplication(secondMockedCallback);
+
+ ReactNativeLibraries.AppRegistry.runApplication('test-app', {});
+
+ expect(firstMockedCallback).toHaveBeenCalled();
+ expect(secondMockedCallback).toHaveBeenCalled();
+ });
+
+ it('registers and executes callback only once', async () => {
+ const mockedCallback = jest.fn();
+ const integration = appRegistryIntegration();
+
+ integration.setupOnce();
+ integration.onRunApplication(mockedCallback);
+ integration.onRunApplication(mockedCallback);
+
+ ReactNativeLibraries.AppRegistry.runApplication('test-app', {});
+
+ expect(mockedCallback).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not patch app registry on web', async () => {
+ jest.spyOn(Environment, 'isWeb').mockReturnValue(true);
+ const integration = appRegistryIntegration();
+
+ integration.setupOnce();
+
+ expect(ReactNativeLibraries.AppRegistry.runApplication).toBe(mockedRunApplication);
+ });
+
+ it('does not crash if AppRegistry is not available', async () => {
+ ReactNativeLibraries.AppRegistry = undefined;
+ const integration = appRegistryIntegration();
+
+ integration.setupOnce();
+ });
+});
diff --git a/packages/core/test/integrations/expocontext.test.ts b/packages/core/test/integrations/expocontext.test.ts
index 91059a4bc2..205fc35b57 100644
--- a/packages/core/test/integrations/expocontext.test.ts
+++ b/packages/core/test/integrations/expocontext.test.ts
@@ -1,102 +1,370 @@
-import type { Client, Event } from '@sentry/core';
+import { type Client, type Event, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core';
-import { expoContextIntegration } from '../../src/js/integrations/expocontext';
+import {
+ expoContextIntegration,
+ getExpoUpdatesContext,
+ OTA_UPDATES_CONTEXT_KEY,
+} from '../../src/js/integrations/expocontext';
+import * as environment from '../../src/js/utils/environment';
+import type { ExpoUpdates } from '../../src/js/utils/expoglobalobject';
import { getExpoDevice } from '../../src/js/utils/expomodules';
+import * as expoModules from '../../src/js/utils/expomodules';
+import { setupTestClient } from '../mocks/client';
+import { NATIVE } from '../mockWrapper';
+jest.mock('../../src/js/wrapper', () => jest.requireActual('../mockWrapper'));
jest.mock('../../src/js/utils/expomodules');
describe('Expo Context Integration', () => {
- it('does not add device context because expo device module is not available', async () => {
- (getExpoDevice as jest.Mock).mockReturnValue(undefined);
- const actualEvent = await executeIntegrationFor({});
+ afterEach(() => {
+ jest.clearAllMocks();
- expect(actualEvent.contexts?.device).toBeUndefined();
+ getCurrentScope().clear();
+ getIsolationScope().clear();
+ getGlobalScope().clear();
});
- it('does not add os context because expo device module is not available', async () => {
- (getExpoDevice as jest.Mock).mockReturnValue(undefined);
- const actualEvent = await executeIntegrationFor({});
+ describe('Set Native Context after init()', () => {
+ beforeEach(() => {
+ jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({
+ updateId: '123',
+ channel: 'default',
+ runtimeVersion: '1.0.0',
+ checkAutomatically: 'always',
+ emergencyLaunchReason: 'some reason',
+ launchDuration: 1000,
+ createdAt: new Date('2021-01-01T00:00:00.000Z'),
+ });
+ });
- expect(actualEvent.contexts?.os).toBeUndefined();
- });
+ it('calls setContext when native enabled', () => {
+ jest.spyOn(environment, 'isExpo').mockReturnValue(true);
+ jest.spyOn(environment, 'isExpoGo').mockReturnValue(false);
+
+ setupTestClient({ enableNative: true, integrations: [expoContextIntegration()] });
- it('adds expo device context', async () => {
- (getExpoDevice as jest.Mock).mockReturnValue({
- deviceName: 'test device name',
- isDevice: true,
- modelName: 'test model name',
- manufacturer: 'test manufacturer',
- totalMemory: 1000,
+ expect(NATIVE.setContext).toHaveBeenCalledWith(
+ OTA_UPDATES_CONTEXT_KEY,
+ expect.objectContaining({
+ update_id: '123',
+ channel: 'default',
+ runtime_version: '1.0.0',
+ }),
+ );
});
- const actualEvent = await executeIntegrationFor({});
- expect(actualEvent.contexts?.device).toStrictEqual({
- name: 'test device name',
- simulator: false,
- model: 'test model name',
- manufacturer: 'test manufacturer',
- memory_size: 1000,
+ it('does not call setContext when native disabled', () => {
+ jest.spyOn(environment, 'isExpo').mockReturnValue(true);
+ jest.spyOn(environment, 'isExpoGo').mockReturnValue(false);
+
+ setupTestClient({ enableNative: false, integrations: [expoContextIntegration()] });
+
+ expect(NATIVE.setContext).not.toHaveBeenCalled();
+ });
+
+ it('does not call setContext when not expo', () => {
+ jest.spyOn(environment, 'isExpo').mockReturnValue(false);
+ jest.spyOn(environment, 'isExpoGo').mockReturnValue(false);
+
+ setupTestClient({ enableNative: true, integrations: [expoContextIntegration()] });
+
+ expect(NATIVE.setContext).not.toHaveBeenCalled();
+ });
+
+ it('does not call setContext when expo go', () => {
+ jest.spyOn(environment, 'isExpo').mockReturnValue(true);
+ jest.spyOn(environment, 'isExpoGo').mockReturnValue(true);
+
+ setupTestClient({ enableNative: true, integrations: [expoContextIntegration()] });
+
+ expect(NATIVE.setContext).not.toHaveBeenCalled();
});
});
- it('adds expo os context', async () => {
- (getExpoDevice as jest.Mock).mockReturnValue({
- osName: 'test os name',
- osBuildId: 'test os build id',
- osVersion: 'test os version',
+ describe('Non Expo App', () => {
+ beforeEach(() => {
+ jest.spyOn(environment, 'isExpo').mockReturnValue(false);
});
- const actualEvent = await executeIntegrationFor({});
- expect(actualEvent.contexts?.os).toStrictEqual({
- name: 'test os name',
- build: 'test os build id',
- version: 'test os version',
+ it('does not add expo updates context', () => {
+ const actualEvent = executeIntegrationFor({});
+
+ expect(actualEvent.contexts?.[OTA_UPDATES_CONTEXT_KEY]).toBeUndefined();
});
});
- it('merge existing event device context with expo', async () => {
- (getExpoDevice as jest.Mock).mockReturnValue({
- deviceName: 'test device name',
- simulator: true,
- modelName: 'test model name',
- manufacturer: 'test manufacturer',
- totalMemory: 1000,
- });
- const actualEvent = await executeIntegrationFor({
- contexts: {
- device: {
- name: 'existing device name',
- },
- },
+ describe('In Expo App', () => {
+ beforeEach(() => {
+ jest.spyOn(environment, 'isExpo').mockReturnValue(true);
+ jest.spyOn(environment, 'isExpoGo').mockReturnValue(false);
+ });
+
+ it('only calls getExpoUpdates once', () => {
+ const getExpoUpdatesMock = jest.spyOn(expoModules, 'getExpoUpdates');
+
+ const integration = expoContextIntegration();
+ integration.processEvent!({}, {}, {} as Client);
+ integration.processEvent!({}, {}, {} as Client);
+
+ expect(getExpoUpdatesMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('added context does not share the same reference', async () => {
+ jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({});
+
+ const integration = expoContextIntegration();
+ const event1 = await integration.processEvent!({}, {}, {} as Client);
+ const event2 = await integration.processEvent!({}, {}, {} as Client);
+
+ expect(event1.contexts![OTA_UPDATES_CONTEXT_KEY]).not.toBe(event2.contexts![OTA_UPDATES_CONTEXT_KEY]);
+ });
+
+ it('adds isEnabled false if ExpoUpdates module is missing', () => {
+ jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue(undefined);
+
+ const actualEvent = executeIntegrationFor({});
+
+ expect(actualEvent.contexts?.[OTA_UPDATES_CONTEXT_KEY]).toStrictEqual({
+ is_enabled: false,
+ });
+ });
+
+ it('adds all bool constants if ExpoUpdate module is empty', () => {
+ jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({});
+
+ const actualEvent = executeIntegrationFor({});
+
+ expect(actualEvent.contexts?.[OTA_UPDATES_CONTEXT_KEY]).toStrictEqual({
+ is_enabled: false,
+ is_embedded_launch: false,
+ is_emergency_launch: false,
+ is_using_embedded_assets: false,
+ });
+ });
+
+ it('adds all non bool constants', () => {
+ jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({
+ updateId: '123',
+ channel: 'default',
+ runtimeVersion: '1.0.0',
+ checkAutomatically: 'always',
+ emergencyLaunchReason: 'some reason',
+ launchDuration: 1000,
+ createdAt: new Date('2021-01-01T00:00:00.000Z'),
+ });
+
+ const actualEvent = executeIntegrationFor({});
+
+ expect(actualEvent.contexts?.[OTA_UPDATES_CONTEXT_KEY]).toEqual({
+ is_enabled: false,
+ is_embedded_launch: false,
+ is_emergency_launch: false,
+ is_using_embedded_assets: false,
+ update_id: '123',
+ channel: 'default',
+ runtime_version: '1.0.0',
+ check_automatically: 'always',
+ emergency_launch_reason: 'some reason',
+ launch_duration: 1000,
+ created_at: '2021-01-01T00:00:00.000Z',
+ });
});
- expect(actualEvent.contexts?.device).toStrictEqual({
- name: 'existing device name',
- simulator: true,
- model: 'test model name',
- manufacturer: 'test manufacturer',
- memory_size: 1000,
+ it('avoids adding values of unexpected types', () => {
+ jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({
+ updateId: {},
+ channel: {},
+ runtimeVersion: {},
+ checkAutomatically: {},
+ emergencyLaunchReason: {},
+ launchDuration: {},
+ createdAt: {},
+ } as unknown as ExpoUpdates);
+
+ const actualEvent = executeIntegrationFor({});
+
+ expect(actualEvent.contexts?.[OTA_UPDATES_CONTEXT_KEY]).toStrictEqual({
+ is_enabled: false,
+ is_embedded_launch: false,
+ is_emergency_launch: false,
+ is_using_embedded_assets: false,
+ });
});
});
- it('merge existing event os context with expo', async () => {
- (getExpoDevice as jest.Mock).mockReturnValue({
- osName: 'test os name',
- osBuildId: 'test os build id',
- osVersion: 'test os version',
+ describe('In Expo Go', () => {
+ beforeEach(() => {
+ jest.spyOn(environment, 'isExpo').mockReturnValue(true);
+ jest.spyOn(environment, 'isExpoGo').mockReturnValue(true);
+ });
+
+ it('does add expo updates context', () => {
+ jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({
+ isEnabled: true,
+ isEmbeddedLaunch: false,
+ updateId: '123',
+ channel: 'default',
+ runtimeVersion: '1.0.0',
+ });
+
+ const actualEvent = executeIntegrationFor({});
+
+ expect(actualEvent.contexts?.[OTA_UPDATES_CONTEXT_KEY]).toStrictEqual({
+ is_enabled: true,
+ is_embedded_launch: false,
+ is_emergency_launch: false,
+ is_using_embedded_assets: false,
+ update_id: '123',
+ channel: 'default',
+ runtime_version: '1.0.0',
+ });
+ });
+
+ it('does not add device context because expo device module is not available', () => {
+ (getExpoDevice as jest.Mock).mockReturnValue(undefined);
+ const actualEvent = executeIntegrationFor({});
+
+ expect(actualEvent.contexts?.device).toBeUndefined();
+ });
+
+ it('does not add os context because expo device module is not available', () => {
+ (getExpoDevice as jest.Mock).mockReturnValue(undefined);
+ const actualEvent = executeIntegrationFor({});
+
+ expect(actualEvent.contexts?.os).toBeUndefined();
+ });
+
+ it('adds expo device context', () => {
+ (getExpoDevice as jest.Mock).mockReturnValue({
+ deviceName: 'test device name',
+ isDevice: true,
+ modelName: 'test model name',
+ manufacturer: 'test manufacturer',
+ totalMemory: 1000,
+ });
+ const actualEvent = executeIntegrationFor({});
+
+ expect(actualEvent.contexts?.device).toStrictEqual({
+ name: 'test device name',
+ simulator: false,
+ model: 'test model name',
+ manufacturer: 'test manufacturer',
+ memory_size: 1000,
+ });
});
- const actualEvent = await executeIntegrationFor({
- contexts: {
- os: {
- name: 'existing os name',
+
+ it('adds expo os context', () => {
+ (getExpoDevice as jest.Mock).mockReturnValue({
+ osName: 'test os name',
+ osBuildId: 'test os build id',
+ osVersion: 'test os version',
+ });
+ const actualEvent = executeIntegrationFor({});
+
+ expect(actualEvent.contexts?.os).toStrictEqual({
+ name: 'test os name',
+ build: 'test os build id',
+ version: 'test os version',
+ });
+ });
+
+ it('merge existing event device context with expo', () => {
+ (getExpoDevice as jest.Mock).mockReturnValue({
+ deviceName: 'test device name',
+ simulator: true,
+ modelName: 'test model name',
+ manufacturer: 'test manufacturer',
+ totalMemory: 1000,
+ });
+ const actualEvent = executeIntegrationFor({
+ contexts: {
+ device: {
+ name: 'existing device name',
+ },
+ },
+ });
+
+ expect(actualEvent.contexts?.device).toStrictEqual({
+ name: 'existing device name',
+ simulator: true,
+ model: 'test model name',
+ manufacturer: 'test manufacturer',
+ memory_size: 1000,
+ });
+ });
+
+ it('merge existing event os context with expo', () => {
+ (getExpoDevice as jest.Mock).mockReturnValue({
+ osName: 'test os name',
+ osBuildId: 'test os build id',
+ osVersion: 'test os version',
+ });
+ const actualEvent = executeIntegrationFor({
+ contexts: {
+ os: {
+ name: 'existing os name',
+ },
},
- },
+ });
+
+ expect(actualEvent.contexts?.os).toStrictEqual({
+ name: 'existing os name',
+ build: 'test os build id',
+ version: 'test os version',
+ });
+ });
+ });
+
+ describe('getExpoUpdatesContext', () => {
+ it('does not return empty values', () => {
+ jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({
+ isEnabled: false,
+ isEmbeddedLaunch: false,
+ isEmergencyLaunch: false,
+ isUsingEmbeddedAssets: false,
+ updateId: '',
+ channel: '',
+ runtimeVersion: '',
+ checkAutomatically: '',
+ emergencyLaunchReason: '',
+ launchDuration: 0,
+ createdAt: new Date('2021-01-01T00:00:00.000Z'),
+ });
+
+ const expoUpdates = getExpoUpdatesContext();
+
+ expect(expoUpdates).toStrictEqual({
+ is_enabled: false,
+ is_embedded_launch: false,
+ is_emergency_launch: false,
+ is_using_embedded_assets: false,
+ launch_duration: 0,
+ created_at: '2021-01-01T00:00:00.000Z',
+ });
});
- expect(actualEvent.contexts?.os).toStrictEqual({
- name: 'existing os name',
- build: 'test os build id',
- version: 'test os version',
+ it('lowercases all string values', () => {
+ jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({
+ updateId: 'UPPERCASE-123',
+ channel: 'UPPERCASE-123',
+ runtimeVersion: 'UPPERCASE-123',
+ checkAutomatically: 'UPPERCASE-123',
+ emergencyLaunchReason: 'This is a description of the reason.',
+ createdAt: new Date('2021-01-01T00:00:00.000Z'),
+ });
+
+ const expoUpdates = getExpoUpdatesContext();
+
+ expect(expoUpdates).toEqual(
+ expect.objectContaining({
+ update_id: 'uppercase-123',
+ channel: 'uppercase-123',
+ runtime_version: 'uppercase-123',
+ check_automatically: 'uppercase-123',
+ emergency_launch_reason: 'This is a description of the reason.', // Description should be kept as is
+ created_at: '2021-01-01T00:00:00.000Z', // Date should keep ISO string format
+ }),
+ );
});
});
diff --git a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts
index aff3b6210f..076008fc61 100644
--- a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts
+++ b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts
@@ -1,16 +1,53 @@
jest.mock('../../src/js/integrations/reactnativeerrorhandlersutils');
+jest.mock('../../src/js/utils/environment');
-import type { ExtendedError, Mechanism, SeverityLevel } from '@sentry/core';
-import { setCurrentClient } from '@sentry/core';
+import type { SeverityLevel } from '@sentry/core';
+import { addGlobalUnhandledRejectionInstrumentationHandler, captureException, setCurrentClient } from '@sentry/core';
import { reactNativeErrorHandlersIntegration } from '../../src/js/integrations/reactnativeerrorhandlers';
-import { requireRejectionTracking } from '../../src/js/integrations/reactnativeerrorhandlersutils';
+import {
+ checkPromiseAndWarn,
+ polyfillPromise,
+ requireRejectionTracking,
+} from '../../src/js/integrations/reactnativeerrorhandlersutils';
+import { isHermesEnabled, isWeb } from '../../src/js/utils/environment';
+import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
+let errorHandlerCallback: ((error: Error, isFatal?: boolean) => Promise) | null = null;
+
+jest.mock('../../src/js/utils/worldwide', () => {
+ const actual = jest.requireActual('../../src/js/utils/worldwide');
+ return {
+ ...actual,
+ RN_GLOBAL_OBJ: {
+ ...actual.RN_GLOBAL_OBJ,
+ ErrorUtils: {
+ setGlobalHandler: jest.fn(callback => {
+ errorHandlerCallback = callback;
+ }),
+ getGlobalHandler: jest.fn(() => jest.fn()),
+ reportError: jest.fn(),
+ },
+ },
+ };
+});
+
+jest.mock('@sentry/core', () => {
+ const actual = jest.requireActual('@sentry/core');
+ return {
+ ...actual,
+ captureException: jest.fn(),
+ addGlobalUnhandledRejectionInstrumentationHandler: jest.fn(),
+ };
+});
+
describe('ReactNativeErrorHandlers', () => {
let client: TestClient;
let mockDisable: jest.Mock;
let mockEnable: jest.Mock;
+ let originalHermesInternal: any;
+ let mockEnablePromiseRejectionTracker: jest.Mock;
beforeEach(() => {
mockDisable = jest.fn();
@@ -19,35 +56,48 @@ describe('ReactNativeErrorHandlers', () => {
disable: mockDisable,
enable: mockEnable,
});
- ErrorUtils.getGlobalHandler = () => jest.fn();
+ (polyfillPromise as jest.Mock).mockImplementation(() => {});
+ (checkPromiseAndWarn as jest.Mock).mockImplementation(() => {});
+
+ errorHandlerCallback = null;
+
+ (isWeb as jest.Mock).mockReturnValue(false);
+ (isHermesEnabled as jest.Mock).mockReturnValue(false);
+
+ originalHermesInternal = RN_GLOBAL_OBJ.HermesInternal;
client = new TestClient(getDefaultTestClientOptions());
setCurrentClient(client);
client.init();
+
+ mockEnablePromiseRejectionTracker = jest.fn();
+ RN_GLOBAL_OBJ.HermesInternal = {
+ enablePromiseRejectionTracker: mockEnablePromiseRejectionTracker,
+ hasPromise: jest.fn(() => true),
+ };
+
+ jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
+ RN_GLOBAL_OBJ.HermesInternal = originalHermesInternal;
});
describe('onError', () => {
- let errorHandlerCallback: (error: Error, isFatal: boolean) => Promise;
-
- beforeEach(() => {
- errorHandlerCallback = () => Promise.resolve();
-
- ErrorUtils.setGlobalHandler = jest.fn(_callback => {
- errorHandlerCallback = _callback as typeof errorHandlerCallback;
- });
-
+ test('Sets up the global error handler', () => {
const integration = reactNativeErrorHandlersIntegration();
-
integration.setupOnce();
- expect(ErrorUtils.setGlobalHandler).toHaveBeenCalledWith(errorHandlerCallback);
+ expect(RN_GLOBAL_OBJ.ErrorUtils.setGlobalHandler).toHaveBeenCalled();
});
test('Sets handled:false on a fatal error', async () => {
+ const integration = reactNativeErrorHandlersIntegration();
+ integration.setupOnce();
+
+ expect(errorHandlerCallback).not.toBeNull();
+
await errorHandlerCallback(new Error('Test Error'), true);
await client.flush();
@@ -59,6 +109,11 @@ describe('ReactNativeErrorHandlers', () => {
});
test('Does not set handled:false on a non-fatal error', async () => {
+ const integration = reactNativeErrorHandlersIntegration();
+ integration.setupOnce();
+
+ expect(errorHandlerCallback).not.toBeNull();
+
await errorHandlerCallback(new Error('Test Error'), false);
await client.flush();
@@ -70,6 +125,11 @@ describe('ReactNativeErrorHandlers', () => {
});
test('Includes original exception in hint', async () => {
+ const integration = reactNativeErrorHandlersIntegration();
+ integration.setupOnce();
+
+ expect(errorHandlerCallback).not.toBeNull();
+
await errorHandlerCallback(new Error('Test Error'), false);
await client.flush();
@@ -80,17 +140,14 @@ describe('ReactNativeErrorHandlers', () => {
});
describe('onUnhandledRejection', () => {
- test('unhandled rejected promise is captured with synthetical error', async () => {
+ test('unhandled rejected promise is captured with JSC approach', async () => {
+ (isWeb as jest.Mock).mockReturnValue(false);
+ (isHermesEnabled as jest.Mock).mockReturnValue(false);
+
const integration = reactNativeErrorHandlersIntegration();
integration.setupOnce();
- const [actualTrackingOptions] = mockEnable.mock.calls[0] || [];
- actualTrackingOptions?.onUnhandled?.(1, 'Test Error');
-
- await client.flush();
- const actualSyntheticError = client.hint?.syntheticException;
-
- expect(mockDisable).not.toHaveBeenCalled();
+ expect(polyfillPromise).toHaveBeenCalled();
expect(mockEnable).toHaveBeenCalledWith(
expect.objectContaining({
allRejections: true,
@@ -98,53 +155,180 @@ describe('ReactNativeErrorHandlers', () => {
onHandled: expect.any(Function),
}),
);
- expect(mockEnable).toHaveBeenCalledTimes(1);
- expect((actualSyntheticError as ExtendedError).framesToPop).toBe(3);
- });
-
- test('error like unhandled rejected promise is captured without synthetical error', async () => {
- const integration = reactNativeErrorHandlersIntegration();
- integration.setupOnce();
- const [actualTrackingOptions] = mockEnable.mock.calls[0] || [];
- actualTrackingOptions?.onUnhandled?.(1, new Error('Test Error'));
+ const [options] = mockEnable.mock.calls[0];
+ const onUnhandledHandler = options.onUnhandled;
- await client.flush();
- const actualSyntheticError = client.hint?.syntheticException;
+ onUnhandledHandler('test-id', 'Test Error');
- expect(mockDisable).not.toHaveBeenCalled();
- expect(mockEnable).toHaveBeenCalledWith(
+ expect(captureException).toHaveBeenCalledWith(
+ 'Test Error',
expect.objectContaining({
- allRejections: true,
- onUnhandled: expect.any(Function),
- onHandled: expect.any(Function),
+ data: { id: 'test-id' },
+ originalException: 'Test Error',
+ mechanism: {
+ handled: true,
+ type: 'onunhandledrejection',
+ },
}),
);
- expect(mockEnable).toHaveBeenCalledTimes(1);
- expect(actualSyntheticError).toBeUndefined();
});
- test('unhandled rejected sets error mechanism', async () => {
+ test('error like unhandled rejected promise is captured without synthetical error', async () => {
+ (isWeb as jest.Mock).mockReturnValue(false);
+ (isHermesEnabled as jest.Mock).mockReturnValue(false);
+
const integration = reactNativeErrorHandlersIntegration();
integration.setupOnce();
- const [actualTrackingOptions] = mockEnable.mock.calls[0] || [];
- actualTrackingOptions?.onUnhandled?.(1, 'Test Error');
+ const [options] = mockEnable.mock.calls[0];
+ const onUnhandledHandler = options.onUnhandled;
- await client.flush();
- const actualSyntheticError = client.hint?.syntheticException;
- const errorMechanism = client.event?.exception?.values[0]?.mechanism;
- expect(mockDisable).not.toHaveBeenCalled();
- expect(mockEnable).toHaveBeenCalledWith(
+ const error = new Error('Test Error');
+ onUnhandledHandler('test-id', error);
+
+ expect(captureException).toHaveBeenCalledWith(
+ error,
expect.objectContaining({
- allRejections: true,
- onUnhandled: expect.any(Function),
- onHandled: expect.any(Function),
+ data: { id: 'test-id' },
+ originalException: error,
+ syntheticException: undefined,
+ mechanism: {
+ handled: true,
+ type: 'onunhandledrejection',
+ },
}),
);
- expect(mockEnable).toHaveBeenCalledTimes(1);
- expect((actualSyntheticError as ExtendedError).framesToPop).toBe(3);
- expect(errorMechanism).toEqual({ handled: true, type: 'onunhandledrejection' } as Mechanism);
+ });
+
+ describe('Hermes engine', () => {
+ beforeEach(() => {
+ (isHermesEnabled as jest.Mock).mockReturnValue(true);
+ });
+
+ test('uses native Hermes promise rejection tracking', () => {
+ const integration = reactNativeErrorHandlersIntegration();
+ integration.setupOnce();
+
+ expect(mockEnablePromiseRejectionTracker).toHaveBeenCalledTimes(1);
+ expect(mockEnablePromiseRejectionTracker).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allRejections: true,
+ onUnhandled: expect.any(Function),
+ onHandled: expect.any(Function),
+ }),
+ );
+
+ expect(polyfillPromise).not.toHaveBeenCalled();
+ });
+
+ test('captures unhandled rejection with Hermes tracker', async () => {
+ const integration = reactNativeErrorHandlersIntegration();
+ integration.setupOnce();
+
+ const [options] = mockEnablePromiseRejectionTracker.mock.calls[0];
+ const onUnhandledHandler = options.onUnhandled;
+
+ const testError = new Error('Hermes Test Error');
+ onUnhandledHandler('hermes-test-error', testError);
+
+ expect(captureException).toHaveBeenCalledWith(
+ testError,
+ expect.objectContaining({
+ data: { id: 'hermes-test-error' },
+ originalException: testError,
+ mechanism: {
+ handled: true,
+ type: 'onunhandledrejection',
+ },
+ }),
+ );
+ });
+ });
+
+ describe('React Native Web', () => {
+ beforeEach(() => {
+ (isWeb as jest.Mock).mockReturnValue(true);
+ (isHermesEnabled as jest.Mock).mockReturnValue(false);
+ });
+
+ test('uses addGlobalUnhandledRejectionInstrumentationHandler for React Native Web', () => {
+ const integration = reactNativeErrorHandlersIntegration();
+ integration.setupOnce();
+
+ expect(addGlobalUnhandledRejectionInstrumentationHandler).toHaveBeenCalledTimes(1);
+ expect(addGlobalUnhandledRejectionInstrumentationHandler).toHaveBeenCalledWith(expect.any(Function));
+
+ // Verify JSC fallback was not used
+ expect(polyfillPromise).not.toHaveBeenCalled();
+ expect(requireRejectionTracking).not.toHaveBeenCalled();
+ });
+
+ test('captures unhandled rejection with the callback', () => {
+ const integration = reactNativeErrorHandlersIntegration();
+ integration.setupOnce();
+
+ const [callback] = (addGlobalUnhandledRejectionInstrumentationHandler as jest.Mock).mock.calls[0];
+
+ const mockError = new Error('Web Test Error');
+ callback(mockError);
+
+ expect(captureException).toHaveBeenCalledWith(
+ mockError,
+ expect.objectContaining({
+ originalException: mockError,
+ mechanism: {
+ handled: false,
+ type: 'onunhandledrejection',
+ },
+ }),
+ );
+ });
+
+ test('handles non-error rejection with synthetic error', () => {
+ const integration = reactNativeErrorHandlersIntegration();
+ integration.setupOnce();
+
+ const [callback] = (addGlobalUnhandledRejectionInstrumentationHandler as jest.Mock).mock.calls[0];
+
+ const nonErrorObject = { message: 'Custom rejection object' };
+ callback(nonErrorObject);
+
+ expect(captureException).toHaveBeenCalledWith(
+ nonErrorObject,
+ expect.objectContaining({
+ originalException: nonErrorObject,
+ syntheticException: expect.anything(),
+ mechanism: {
+ handled: false,
+ type: 'onunhandledrejection',
+ },
+ }),
+ );
+ });
+ });
+
+ describe('JSC and other environments', () => {
+ beforeEach(() => {
+ (isHermesEnabled as jest.Mock).mockReturnValue(false);
+ (isWeb as jest.Mock).mockReturnValue(false);
+ });
+
+ test('uses existing polyfill for JSC environments', () => {
+ const integration = reactNativeErrorHandlersIntegration();
+ integration.setupOnce();
+
+ expect(polyfillPromise).toHaveBeenCalledTimes(1);
+ expect(requireRejectionTracking).toHaveBeenCalledTimes(1);
+ });
+
+ test('respects patchGlobalPromise option', () => {
+ const integration = reactNativeErrorHandlersIntegration({ patchGlobalPromise: false });
+ integration.setupOnce();
+
+ expect(polyfillPromise).not.toHaveBeenCalled();
+ expect(requireRejectionTracking).not.toHaveBeenCalled();
+ });
});
});
});
diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts
index fe6a611394..c26c384e26 100644
--- a/packages/core/test/mockWrapper.ts
+++ b/packages/core/test/mockWrapper.ts
@@ -58,8 +58,11 @@ const NATIVE: MockInterface = {
getCurrentReplayId: jest.fn(),
crashedLastRun: jest.fn(),
- getNewScreenTimeToDisplay: jest.fn().mockResolvedValue(42),
+ getNewScreenTimeToDisplay: jest.fn(),
getDataFromUri: jest.fn(),
+ popTimeToDisplayFor: jest.fn(),
+ setActiveSpanId: jest.fn(),
+ encodeToBase64: jest.fn(),
};
NATIVE.isNativeAvailable.mockReturnValue(true);
@@ -84,6 +87,8 @@ NATIVE.initNativeReactNavigationNewFrameTracking.mockReturnValue(Promise.resolve
NATIVE.captureReplay.mockResolvedValue(null);
NATIVE.getCurrentReplayId.mockReturnValue(null);
NATIVE.crashedLastRun.mockResolvedValue(false);
+NATIVE.popTimeToDisplayFor.mockResolvedValue(null);
+NATIVE.getNewScreenTimeToDisplay.mockResolvedValue(null);
export const getRNSentryModule = jest.fn();
diff --git a/packages/core/test/mocks/appRegistryIntegrationMock.ts b/packages/core/test/mocks/appRegistryIntegrationMock.ts
new file mode 100644
index 0000000000..12fc0dd273
--- /dev/null
+++ b/packages/core/test/mocks/appRegistryIntegrationMock.ts
@@ -0,0 +1,14 @@
+import * as AppRegistry from '../../src/js/integrations/appRegistry';
+
+export const mockAppRegistryIntegration = () => {
+ const mockedOnRunApplication = jest.fn();
+ const mockedGetAppRegistryIntegration = jest.spyOn(AppRegistry, 'getAppRegistryIntegration').mockReturnValue({
+ onRunApplication: mockedOnRunApplication,
+ name: 'appRegistryIntegrationMocked',
+ });
+
+ return {
+ mockedOnRunApplication,
+ mockedGetAppRegistryIntegration,
+ };
+};
diff --git a/packages/core/test/mocks/client.ts b/packages/core/test/mocks/client.ts
index 8a0df75ebc..1a18e94365 100644
--- a/packages/core/test/mocks/client.ts
+++ b/packages/core/test/mocks/client.ts
@@ -114,5 +114,9 @@ export function setupTestClient(options: Partial = {}): TestC
const client = new TestClient(finalOptions);
setCurrentClient(client);
client.init();
+
+ // @ts-expect-error Only available on ReactNativeClient
+ client.emit('afterInit');
+
return client;
}
diff --git a/packages/core/test/profiling/fixtures.ts b/packages/core/test/profiling/fixtures.ts
index 7a4e777598..f406a613f3 100644
--- a/packages/core/test/profiling/fixtures.ts
+++ b/packages/core/test/profiling/fixtures.ts
@@ -80,6 +80,15 @@ export function createMockMinimalValidHermesProfileEvent(): HermesProfileEvent {
};
}
+/**
+ * Create a mock native (iOS/Apple) profile.
+ */
+export function createMockMinimalValidAppleProfileWithoutDebugMeta(): NativeProfileEvent {
+ const profile = createMockMinimalValidAppleProfile();
+ delete profile.debug_meta;
+ return profile;
+}
+
/**
* Create a mock native (iOS/Apple) profile.
*/
diff --git a/packages/core/test/profiling/integration.test.ts b/packages/core/test/profiling/integration.test.ts
index 83da5cc53d..43a7d3cc75 100644
--- a/packages/core/test/profiling/integration.test.ts
+++ b/packages/core/test/profiling/integration.test.ts
@@ -17,6 +17,7 @@ import { envelopeItemPayload, envelopeItems } from '../testutils';
import {
createMockMinimalValidAndroidProfile,
createMockMinimalValidAppleProfile,
+ createMockMinimalValidAppleProfileWithoutDebugMeta,
createMockMinimalValidHermesProfile,
} from './fixtures';
@@ -155,6 +156,41 @@ describe('profiling integration', () => {
});
describe('with native profiling', () => {
+ test('should create a new mixed profile and add it to the transaction envelope with missing debug_meta', () => {
+ mockWrapper.NATIVE.stopProfiling.mockReturnValue({
+ hermesProfile: createMockMinimalValidHermesProfile(),
+ nativeProfile: createMockMinimalValidAppleProfileWithoutDebugMeta(),
+ });
+
+ const transaction = Sentry.startSpan({ name: 'test-name' }, span => span);
+
+ jest.runAllTimers();
+
+ const envelope: Envelope | undefined = mock.transportSendMock.mock.lastCall?.[0];
+ expectEnvelopeToContainProfile(envelope, 'test-name', spanToJSON(transaction).trace_id);
+ // Expect merged profile
+ expect(getProfileFromEnvelope(envelope)).toEqual(
+ expect.objectContaining(>{
+ profile: expect.objectContaining(>{
+ frames: [
+ {
+ function: '[root]',
+ in_app: false,
+ },
+ {
+ instruction_addr: '0x0000000000000003',
+ platform: 'cocoa',
+ },
+ {
+ instruction_addr: '0x0000000000000004',
+ platform: 'cocoa',
+ },
+ ],
+ }),
+ }),
+ );
+ });
+
test('should create a new mixed profile and add it to the transaction envelope', () => {
mockWrapper.NATIVE.stopProfiling.mockReturnValue({
hermesProfile: createMockMinimalValidHermesProfile(),
@@ -262,8 +298,9 @@ describe('profiling integration', () => {
const transaction1 = Sentry.startSpanManual({ name: 'test-name-1' }, span => span);
const transaction2 = Sentry.startSpanManual({ name: 'test-name-2' }, span => span);
transaction1.end();
- transaction2.end();
+ jest.runOnlyPendingTimers();
+ transaction2.end();
jest.runAllTimers();
expectEnvelopeToContainProfile(
diff --git a/packages/core/test/replay/CustomMask.test.ts b/packages/core/test/replay/CustomMask.test.ts
index 7ae7978665..af8d677a62 100644
--- a/packages/core/test/replay/CustomMask.test.ts
+++ b/packages/core/test/replay/CustomMask.test.ts
@@ -2,9 +2,23 @@ import { beforeEach, describe, expect, it } from '@jest/globals';
describe('CustomMask', () => {
beforeEach(() => {
+ jest.mock('../../src/js/utils/environment', () => ({
+ isExpoGo: () => false,
+ }));
jest.resetModules();
});
+ it('returns a fallback when isExpoGo is true', () => {
+ jest.mock('../../src/js/utils/environment', () => ({
+ isExpoGo: () => true,
+ }));
+
+ const { Mask, Unmask, MaskFallback, UnmaskFallback } = require('../../src/js/replay/CustomMask');
+
+ expect(Mask).toBe(MaskFallback);
+ expect(Unmask).toBe(UnmaskFallback);
+ });
+
it('returns a fallback when native view manager is missing', () => {
jest.mock('react-native', () => ({
UIManager: {},
diff --git a/packages/core/test/sdk.test.ts b/packages/core/test/sdk.test.ts
index 0e64264899..3affd059cc 100644
--- a/packages/core/test/sdk.test.ts
+++ b/packages/core/test/sdk.test.ts
@@ -780,6 +780,37 @@ describe('Tests the SDK functionality', () => {
});
});
+ describe('app registry integration', () => {
+ test('no integration when tracing disabled', () => {
+ init({});
+
+ expectNotIntegration('AppRegistry');
+ });
+ test('integration added when tracing enabled', () => {
+ init({
+ tracesSampleRate: 0.5,
+ });
+
+ expectIntegration('AppRegistry');
+ });
+ });
+
+ describe('time to display integration', () => {
+ it('no integration when tracing disabled', () => {
+ init({});
+
+ expectNotIntegration('TimeToDisplay');
+ });
+
+ it('integration added when tracing enabled', () => {
+ init({
+ tracesSampleRate: 0.5,
+ });
+
+ expectIntegration('TimeToDisplay');
+ });
+ });
+
it('adds spotlight integration with spotlight bool', () => {
init({
spotlight: true,
@@ -904,6 +935,7 @@ describe('Tests the SDK functionality', () => {
expectIntegration('EventOrigin');
expectIntegration('SdkInfo');
expectIntegration('ReactNativeInfo');
+ expectIntegration('ExpoContext');
});
it('adds web platform specific default integrations', () => {
diff --git a/packages/core/test/testutils.ts b/packages/core/test/testutils.ts
index 76bdd990a6..bf0aa9f3d7 100644
--- a/packages/core/test/testutils.ts
+++ b/packages/core/test/testutils.ts
@@ -62,6 +62,10 @@ export const createMockTransport = (): MockInterface => {
};
};
+export const nowInSeconds = (): number => {
+ return Date.now() / 1000;
+};
+
export const secondAgoTimestampMs = (): number => {
return new Date(Date.now() - 1000).getTime();
};
diff --git a/packages/core/test/tools/metroconfig.test.ts b/packages/core/test/tools/metroconfig.test.ts
index 7fc19888f1..2329509754 100644
--- a/packages/core/test/tools/metroconfig.test.ts
+++ b/packages/core/test/tools/metroconfig.test.ts
@@ -169,72 +169,74 @@ describe('metroconfig', () => {
}));
});
- test('keep Web Replay when platform is web and includeWebReplay is true', () => {
- const modifiedConfig = withSentryResolver(config, true);
- resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'web');
+ describe.each([['@sentry/replay'], ['@sentry-internal/replay']])('with %s', replayPackage => {
+ test('keep Web Replay when platform is web and includeWebReplay is true', () => {
+ const modifiedConfig = withSentryResolver(config, true);
+ resolveRequest(modifiedConfig, contextMock, replayPackage, 'web');
- ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, '@sentry/replay', 'web');
- });
+ ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, replayPackage, 'web');
+ });
- test('removes Web Replay when platform is web and includeWebReplay is false', () => {
- const modifiedConfig = withSentryResolver(config, false);
- const result = resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'web');
+ test('removes Web Replay when platform is web and includeWebReplay is false', () => {
+ const modifiedConfig = withSentryResolver(config, false);
+ const result = resolveRequest(modifiedConfig, contextMock, replayPackage, 'web');
- expect(result).toEqual({ type: 'empty' });
- expect(originalResolverMock).not.toHaveBeenCalled();
- });
+ expect(result).toEqual({ type: 'empty' });
+ expect(originalResolverMock).not.toHaveBeenCalled();
+ });
- test('keep Web Replay when platform is android and includeWebReplay is true', () => {
- const modifiedConfig = withSentryResolver(config, true);
- resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'android');
+ test('keep Web Replay when platform is android and includeWebReplay is true', () => {
+ const modifiedConfig = withSentryResolver(config, true);
+ resolveRequest(modifiedConfig, contextMock, replayPackage, 'android');
- ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, '@sentry/replay', 'android');
- });
+ ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, replayPackage, 'android');
+ });
- test('removes Web Replay when platform is android and includeWebReplay is false', () => {
- const modifiedConfig = withSentryResolver(config, false);
- const result = resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'android');
+ test('removes Web Replay when platform is android and includeWebReplay is false', () => {
+ const modifiedConfig = withSentryResolver(config, false);
+ const result = resolveRequest(modifiedConfig, contextMock, replayPackage, 'android');
- expect(result).toEqual({ type: 'empty' });
- expect(originalResolverMock).not.toHaveBeenCalled();
- });
+ expect(result).toEqual({ type: 'empty' });
+ expect(originalResolverMock).not.toHaveBeenCalled();
+ });
- test('removes Web Replay when platform is android and includeWebReplay is undefined', () => {
- const modifiedConfig = withSentryResolver(config, undefined);
- const result = resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'android');
+ test('removes Web Replay when platform is android and includeWebReplay is undefined', () => {
+ const modifiedConfig = withSentryResolver(config, undefined);
+ const result = resolveRequest(modifiedConfig, contextMock, replayPackage, 'android');
- expect(result).toEqual({ type: 'empty' });
- expect(originalResolverMock).not.toHaveBeenCalled();
- });
+ expect(result).toEqual({ type: 'empty' });
+ expect(originalResolverMock).not.toHaveBeenCalled();
+ });
- test('keep Web Replay when platform is undefined and includeWebReplay is null', () => {
- const modifiedConfig = withSentryResolver(config, undefined);
- resolveRequest(modifiedConfig, contextMock, '@sentry/replay', null);
+ test('keep Web Replay when platform is undefined and includeWebReplay is null', () => {
+ const modifiedConfig = withSentryResolver(config, undefined);
+ resolveRequest(modifiedConfig, contextMock, replayPackage, null);
- ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, '@sentry/replay', null);
- });
+ ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, replayPackage, null);
+ });
- test('keep Web Replay when platform is ios and includeWebReplay is true', () => {
- const modifiedConfig = withSentryResolver(config, true);
- resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'ios');
+ test('keep Web Replay when platform is ios and includeWebReplay is true', () => {
+ const modifiedConfig = withSentryResolver(config, true);
+ resolveRequest(modifiedConfig, contextMock, replayPackage, 'ios');
- ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, '@sentry/replay', 'ios');
- });
+ ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, replayPackage, 'ios');
+ });
- test('removes Web Replay when platform is ios and includeWebReplay is false', () => {
- const modifiedConfig = withSentryResolver(config, false);
- const result = resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'ios');
+ test('removes Web Replay when platform is ios and includeWebReplay is false', () => {
+ const modifiedConfig = withSentryResolver(config, false);
+ const result = resolveRequest(modifiedConfig, contextMock, replayPackage, 'ios');
- expect(result).toEqual({ type: 'empty' });
- expect(originalResolverMock).not.toHaveBeenCalled();
- });
+ expect(result).toEqual({ type: 'empty' });
+ expect(originalResolverMock).not.toHaveBeenCalled();
+ });
- test('removes Web Replay when platform is ios and includeWebReplay is undefined', () => {
- const modifiedConfig = withSentryResolver(config, undefined);
- const result = resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'ios');
+ test('removes Web Replay when platform is ios and includeWebReplay is undefined', () => {
+ const modifiedConfig = withSentryResolver(config, undefined);
+ const result = resolveRequest(modifiedConfig, contextMock, replayPackage, 'ios');
- expect(result).toEqual({ type: 'empty' });
- expect(originalResolverMock).not.toHaveBeenCalled();
+ expect(result).toEqual({ type: 'empty' });
+ expect(originalResolverMock).not.toHaveBeenCalled();
+ });
});
test('calls originalResolver when moduleName is not @sentry/replay', () => {
diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts
index 4337e3e2b3..b31c35db66 100644
--- a/packages/core/test/tracing/integrations/appStart.test.ts
+++ b/packages/core/test/tracing/integrations/appStart.test.ts
@@ -1,4 +1,4 @@
-import type { ErrorEvent, Event, SpanJSON, TransactionEvent } from '@sentry/core';
+import type { ErrorEvent, Event, Integration, SpanJSON, TransactionEvent } from '@sentry/core';
import {
getCurrentScope,
getGlobalScope,
@@ -27,12 +27,18 @@ import {
setRootComponentCreationTimestampMs,
} from '../../../src/js/tracing/integrations/appStart';
import { SPAN_ORIGIN_AUTO_APP_START, SPAN_ORIGIN_MANUAL_APP_START } from '../../../src/js/tracing/origin';
+import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_MAIN } from '../../../src/js/tracing/span';
import { getTimeOriginMilliseconds } from '../../../src/js/tracing/utils';
import { RN_GLOBAL_OBJ } from '../../../src/js/utils/worldwide';
import { NATIVE } from '../../../src/js/wrapper';
+import { mockAppRegistryIntegration } from '../../mocks/appRegistryIntegrationMock';
import { getDefaultTestClientOptions, TestClient } from '../../mocks/client';
import { mockFunction } from '../../testutils';
+type AppStartIntegrationTest = ReturnType & {
+ setFirstStartedActiveRootSpanId: (spanId: string | undefined) => void;
+};
+
let dateNowSpy: jest.SpyInstance;
jest.mock('../../../src/js/wrapper', () => {
@@ -122,11 +128,15 @@ describe('App Start Integration', () => {
it('Does add App Start Span older than threshold in development builds', async () => {
set__DEV__(true);
- const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockTooOldAppStart();
+ const [timeOriginMilliseconds, appStartTimeMilliseconds, appStartDurationMilliseconds] = mockTooOldAppStart();
const actualEvent = await captureStandAloneAppStart();
expect(actualEvent).toEqual(
- expectEventWithStandaloneWarmAppStart(actualEvent, { timeOriginMilliseconds, appStartTimeMilliseconds }),
+ expectEventWithStandaloneWarmAppStart(actualEvent, {
+ timeOriginMilliseconds,
+ appStartTimeMilliseconds,
+ appStartDurationMilliseconds,
+ }),
);
});
@@ -252,6 +262,7 @@ describe('App Start Integration', () => {
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: appStartRootSpan!.op,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START,
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_MAIN,
},
}),
);
@@ -426,21 +437,27 @@ describe('App Start Integration', () => {
it('Does not add App Start Span older than threshold', async () => {
set__DEV__(false);
- mockTooOldAppStart();
+ const [timeOriginMilliseconds] = mockTooOldAppStart();
- const actualEvent = await processEvent(getMinimalTransactionEvent());
- expect(actualEvent).toStrictEqual(getMinimalTransactionEvent());
+ const actualEvent = await processEvent(
+ getMinimalTransactionEvent({ startTimestampSeconds: timeOriginMilliseconds }),
+ );
+ expect(actualEvent).toStrictEqual(getMinimalTransactionEvent({ startTimestampSeconds: timeOriginMilliseconds }));
});
it('Does add App Start Span older than threshold in development builds', async () => {
set__DEV__(true);
- const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockTooOldAppStart();
+ const [timeOriginMilliseconds, appStartTimeMilliseconds, appStartDurationMilliseconds] = mockTooOldAppStart();
const actualEvent = await processEvent(
getMinimalTransactionEvent({ startTimestampSeconds: timeOriginMilliseconds }),
);
expect(actualEvent).toEqual(
- expectEventWithAttachedWarmAppStart({ timeOriginMilliseconds, appStartTimeMilliseconds }),
+ expectEventWithAttachedWarmAppStart({
+ timeOriginMilliseconds,
+ appStartTimeMilliseconds,
+ appStartDurationMilliseconds,
+ }),
);
});
@@ -612,6 +629,7 @@ describe('App Start Integration', () => {
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: appStartRootSpan!.op,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START,
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_MAIN,
},
}),
);
@@ -683,13 +701,63 @@ describe('App Start Integration', () => {
);
});
+ it('run application before initial app start is flushed is ignored, app start is attached only once', async () => {
+ const { mockedOnRunApplication } = mockAppRegistryIntegration();
+ mockAppStart({ cold: true });
+
+ const event = getMinimalTransactionEvent();
+ const integration = setupIntegration();
+ (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(event.contexts?.trace?.span_id);
+
+ const registerAppStartCallback = mockedOnRunApplication.mock.calls[0][0];
+ registerAppStartCallback();
+
+ const actualFirstEvent = await processEventWithIntegration(integration, event);
+ const actualSecondEvent = await processEventWithIntegration(integration, getMinimalTransactionEvent());
+
+ expect(actualFirstEvent.measurements[APP_START_COLD_MEASUREMENT]).toBeDefined();
+ expect(actualSecondEvent.measurements).toBeUndefined();
+ });
+
+ it('run application after initial app start is flushed allows attaching app start to the next root span', async () => {
+ const { mockedOnRunApplication } = mockAppRegistryIntegration();
+ mockAppStart({ cold: true });
+
+ const firstEvent = getMinimalTransactionEvent();
+ const integration = setupIntegration();
+ (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(firstEvent.contexts?.trace?.span_id);
+
+ const actualFirstEvent = await processEventWithIntegration(integration, firstEvent);
+
+ const registerAppStartCallback = mockedOnRunApplication.mock.calls[0][0];
+ registerAppStartCallback();
+
+ mockAppStart({ cold: false });
+ const secondEvent = getMinimalTransactionEvent();
+ (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(secondEvent.contexts?.trace?.span_id);
+ const actualSecondEvent = await processEventWithIntegration(integration, secondEvent);
+
+ expect(actualFirstEvent.measurements[APP_START_COLD_MEASUREMENT]).toBeDefined();
+ expect(actualSecondEvent.measurements[APP_START_WARM_MEASUREMENT]).toBeDefined();
+ });
+
+ it('Does not add app start span if app start end timestamp is before app start timestamp', async () => {
+ mockAppStart({ cold: true, appStartEndTimestampMs: Date.now() - 1000 });
+
+ const actualEvent = await processEvent(getMinimalTransactionEvent());
+ expect(actualEvent.measurements).toBeUndefined();
+ });
+
it('Does not add app start span twice', async () => {
const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStart({ cold: true });
const integration = appStartIntegration();
const client = new TestClient(getDefaultTestClientOptions());
- const actualEvent = await integration.processEvent(getMinimalTransactionEvent(), {}, client);
+ const firstEvent = getMinimalTransactionEvent();
+ (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(firstEvent.contexts?.trace?.span_id);
+
+ const actualEvent = await integration.processEvent(firstEvent, {}, client);
expect(actualEvent).toEqual(
expectEventWithAttachedColdAppStart({ timeOriginMilliseconds, appStartTimeMilliseconds }),
);
@@ -720,11 +788,24 @@ describe('App Start Integration', () => {
});
});
-function processEvent(event: Event): PromiseLike | Event | null {
+function setupIntegration() {
+ const client = new TestClient(getDefaultTestClientOptions());
const integration = appStartIntegration();
+ integration.afterAllSetup(client);
+
+ return integration;
+}
+
+function processEventWithIntegration(integration: Integration, event: Event) {
return integration.processEvent(event, {}, new TestClient(getDefaultTestClientOptions()));
}
+function processEvent(event: Event): PromiseLike | Event | null {
+ const integration = setupIntegration();
+ (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(event.contexts?.trace?.span_id);
+ return processEventWithIntegration(integration, event);
+}
+
async function captureStandAloneAppStart(): Promise | Event | null> {
getCurrentScope().clear();
getIsolationScope().clear();
@@ -830,9 +911,11 @@ function expectEventWithAttachedColdAppStart({
function expectEventWithAttachedWarmAppStart({
timeOriginMilliseconds,
appStartTimeMilliseconds,
+ appStartDurationMilliseconds,
}: {
timeOriginMilliseconds: number;
appStartTimeMilliseconds: number;
+ appStartDurationMilliseconds?: number;
}) {
return expect.objectContaining({
type: 'transaction',
@@ -849,7 +932,7 @@ function expectEventWithAttachedWarmAppStart({
}),
measurements: expect.objectContaining({
[APP_START_WARM_MEASUREMENT]: {
- value: timeOriginMilliseconds - appStartTimeMilliseconds,
+ value: appStartDurationMilliseconds || timeOriginMilliseconds - appStartTimeMilliseconds,
unit: 'millisecond',
},
}),
@@ -935,9 +1018,11 @@ function expectEventWithStandaloneWarmAppStart(
{
timeOriginMilliseconds,
appStartTimeMilliseconds,
+ appStartDurationMilliseconds,
}: {
timeOriginMilliseconds: number;
appStartTimeMilliseconds: number;
+ appStartDurationMilliseconds?: number;
},
) {
return expect.objectContaining({
@@ -955,7 +1040,7 @@ function expectEventWithStandaloneWarmAppStart(
}),
measurements: expect.objectContaining({
[APP_START_WARM_MEASUREMENT]: {
- value: timeOriginMilliseconds - appStartTimeMilliseconds,
+ value: appStartDurationMilliseconds || timeOriginMilliseconds - appStartTimeMilliseconds,
unit: 'millisecond',
},
}),
@@ -984,12 +1069,14 @@ function mockAppStart({
has_fetched = false,
enableNativeSpans = false,
customNativeSpans = [],
+ appStartEndTimestampMs = undefined,
}: {
cold?: boolean;
has_fetched?: boolean;
enableNativeSpans?: boolean;
customNativeSpans?: NativeAppStartResponse['spans'];
-}) {
+ appStartEndTimestampMs?: number;
+} = {}) {
const timeOriginMilliseconds = Date.now();
const appStartTimeMilliseconds = timeOriginMilliseconds - 100;
const mockAppStartResponse: NativeAppStartResponse = {
@@ -1008,7 +1095,7 @@ function mockAppStart({
: [],
};
- _setAppStartEndTimestampMs(timeOriginMilliseconds);
+ _setAppStartEndTimestampMs(appStartEndTimestampMs || timeOriginMilliseconds);
mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds);
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
@@ -1034,7 +1121,10 @@ function mockTooLongAppStart() {
function mockTooOldAppStart() {
const timeOriginMilliseconds = Date.now();
+ // Ensures app start is old (more than 1 minute before transaction start)
const appStartTimeMilliseconds = timeOriginMilliseconds - 65000;
+ const appStartEndTimestampMilliseconds = appStartTimeMilliseconds + 5000;
+ const appStartDurationMilliseconds = appStartEndTimestampMilliseconds - appStartTimeMilliseconds;
const mockAppStartResponse: NativeAppStartResponse = {
type: 'warm',
app_start_timestamp_ms: appStartTimeMilliseconds,
@@ -1043,13 +1133,14 @@ function mockTooOldAppStart() {
};
// App start finish timestamp
- _setAppStartEndTimestampMs(timeOriginMilliseconds);
+ // App start length is 5 seconds
+ _setAppStartEndTimestampMs(appStartEndTimestampMilliseconds);
mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds - 64000);
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
// Transaction start timestamp
mockFunction(timestampInSeconds).mockReturnValue(timeOriginMilliseconds / 1000 + 65);
- return [timeOriginMilliseconds, appStartTimeMilliseconds];
+ return [timeOriginMilliseconds, appStartTimeMilliseconds, appStartDurationMilliseconds];
}
/**
diff --git a/packages/core/test/tracing/mockedtimetodisplaynative.tsx b/packages/core/test/tracing/mockedtimetodisplaynative.tsx
index 14c78fc5e0..6fbc773b53 100644
--- a/packages/core/test/tracing/mockedtimetodisplaynative.tsx
+++ b/packages/core/test/tracing/mockedtimetodisplaynative.tsx
@@ -1,7 +1,8 @@
import * as React from 'react';
import { View } from 'react-native';
-import type { RNSentryOnDrawNextFrameEvent, RNSentryOnDrawReporterProps } from '../../src/js/tracing/timetodisplaynative.types';
+import type { RNSentryOnDrawReporterProps } from '../../src/js/tracing/timetodisplaynative.types';
+import { NATIVE } from '../mockWrapper';
export let nativeComponentExists = true;
@@ -9,18 +10,44 @@ export function setMockedNativeComponentExists(value: boolean): void {
nativeComponentExists = value;
}
-export let mockedOnDrawNextFrame: (event: { nativeEvent: RNSentryOnDrawNextFrameEvent }) => void;
+/**
+ * {
+ * [spanId]: timestampInSeconds,
+ * }
+ */
+export function mockRecordedTimeToDisplay({
+ ttidNavigation = {},
+ ttid = {},
+ ttfd = {},
+}: {
+ 'ttidNavigation'?: Record,
+ ttid?: Record,
+ ttfd?: Record,
+}): void {
+ NATIVE.popTimeToDisplayFor.mockImplementation((key: string) => {
+ if (key.startsWith('ttid-navigation-')) {
+ return Promise.resolve(ttidNavigation[key.substring(16)]);
+ } else if (key.startsWith('ttid-')) {
+ return Promise.resolve(ttid[key.substring(5)]);
+ } else if (key.startsWith('ttfd-')) {
+ return Promise.resolve(ttfd[key.substring(5)]);
+ }
+ return Promise.resolve(undefined);
+ });
+}
+
+let mockedProps: RNSentryOnDrawReporterProps[] = [];
-export function emitNativeInitialDisplayEvent(frameTimestampMs?: number): void {
- mockedOnDrawNextFrame({ nativeEvent: { type: 'initialDisplay', newFrameTimestampInSeconds: (frameTimestampMs || Date.now()) / 1_000 } });
+export function getMockedOnDrawReportedProps(): RNSentryOnDrawReporterProps[] {
+ return mockedProps;
}
-export function emitNativeFullDisplayEvent(frameTimestampMs?: number): void {
- mockedOnDrawNextFrame({ nativeEvent: { type: 'fullDisplay', newFrameTimestampInSeconds: (frameTimestampMs || Date.now()) / 1_000 } });
+export function clearMockedOnDrawReportedProps(): void {
+ mockedProps = [];
}
function RNSentryOnDrawReporterMock(props: RNSentryOnDrawReporterProps): React.ReactElement {
- mockedOnDrawNextFrame = props.onDrawNextFrame;
+ mockedProps.push(props);
return ;
}
diff --git a/packages/core/test/tracing/reactnativenavigation.test.ts b/packages/core/test/tracing/reactnativenavigation.test.ts
index 09acbaa25f..ebc264d91f 100644
--- a/packages/core/test/tracing/reactnativenavigation.test.ts
+++ b/packages/core/test/tracing/reactnativenavigation.test.ts
@@ -32,6 +32,7 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '../../src/js/tracing/semanticAttributes';
+import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
interface MockEventsRegistry extends EventsRegistry {
@@ -87,6 +88,7 @@ describe('React Native Navigation Instrumentation', () => {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout',
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
}),
}),
@@ -131,6 +133,7 @@ describe('React Native Navigation Instrumentation', () => {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout',
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
}),
}),
@@ -206,6 +209,7 @@ describe('React Native Navigation Instrumentation', () => {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout',
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
}),
}),
@@ -294,6 +298,7 @@ describe('React Native Navigation Instrumentation', () => {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout',
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
}),
}),
@@ -342,6 +347,7 @@ describe('React Native Navigation Instrumentation', () => {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout',
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
}),
}),
diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts
index 5c8b805e9d..54a203000f 100644
--- a/packages/core/test/tracing/reactnavigation.test.ts
+++ b/packages/core/test/tracing/reactnavigation.test.ts
@@ -2,7 +2,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { SentrySpan } from '@sentry/core';
import type { Event, Measurements, StartSpanOptions } from '@sentry/core';
-import { getActiveSpan, getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient } from '@sentry/core';
+import {
+ getActiveSpan,
+ getCurrentScope,
+ getGlobalScope,
+ getIsolationScope,
+ setCurrentClient,
+ spanToJSON,
+} from '@sentry/core';
import { nativeFramesIntegration, reactNativeTracingIntegration } from '../../src/js';
import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from '../../src/js/tracing/origin';
@@ -20,8 +27,9 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '../../src/js/tracing/semanticAttributes';
-import { DEFAULT_NAVIGATION_SPAN_NAME } from '../../src/js/tracing/span';
+import { DEFAULT_NAVIGATION_SPAN_NAME, SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span';
import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide';
+import { mockAppRegistryIntegration } from '../mocks/appRegistryIntegrationMock';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
import { NATIVE } from '../mockWrapper';
import { getDevServer } from './../../src/js/integrations/debugsymbolicatorutils';
@@ -54,6 +62,7 @@ describe('ReactNavigationInstrumentation', () => {
let mockNavigation: ReturnType;
beforeEach(() => {
+ jest.clearAllMocks();
RN_GLOBAL_OBJ.__sentry_rn_v5_registered = false;
getCurrentScope().clear();
@@ -83,6 +92,7 @@ describe('ReactNavigationInstrumentation', () => {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout',
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
}),
}),
@@ -192,6 +202,7 @@ describe('ReactNavigationInstrumentation', () => {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout',
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
}),
}),
@@ -229,6 +240,7 @@ describe('ReactNavigationInstrumentation', () => {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout',
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
}),
}),
@@ -268,6 +280,7 @@ describe('ReactNavigationInstrumentation', () => {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout',
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
}),
}),
@@ -324,6 +337,7 @@ describe('ReactNavigationInstrumentation', () => {
expect(RN_GLOBAL_OBJ.__sentry_rn_v5_registered).toBe(true);
+ expect(mockNavigationContainer.addListener).toHaveBeenCalledTimes(2);
expect(mockNavigationContainer.addListener).toHaveBeenNthCalledWith(1, '__unsafe_action__', expect.any(Function));
expect(mockNavigationContainer.addListener).toHaveBeenNthCalledWith(2, 'state', expect.any(Function));
});
@@ -335,23 +349,71 @@ describe('ReactNavigationInstrumentation', () => {
expect(RN_GLOBAL_OBJ.__sentry_rn_v5_registered).toBe(true);
+ expect(mockNavigationContainer.addListener).toHaveBeenCalledTimes(2);
expect(mockNavigationContainer.addListener).toHaveBeenNthCalledWith(1, '__unsafe_action__', expect.any(Function));
expect(mockNavigationContainer.addListener).toHaveBeenNthCalledWith(2, 'state', expect.any(Function));
});
- test('does not register navigation container if there is an existing one', () => {
- RN_GLOBAL_OBJ.__sentry_rn_v5_registered = true;
-
+ test('does not register navigation container if the existing one is already registered', () => {
const instrumentation = reactNavigationIntegration();
+
const mockNavigationContainer = new MockNavigationContainer();
instrumentation.registerNavigationContainer({
current: mockNavigationContainer,
});
+ // Clear mocks after the first registration
+ jest.clearAllMocks();
+
+ instrumentation.registerNavigationContainer({
+ current: mockNavigationContainer,
+ });
+
expect(RN_GLOBAL_OBJ.__sentry_rn_v5_registered).toBe(true);
expect(mockNavigationContainer.addListener).not.toHaveBeenCalled();
- expect(mockNavigationContainer.addListener).not.toHaveBeenCalled();
+ });
+
+ test('does register navigation container if received a new reference', () => {
+ const instrumentation = reactNavigationIntegration();
+ const refHolder: { current: MockNavigationContainer | undefined } = { current: undefined };
+ const firstContainer = new MockNavigationContainer();
+ const secondContainer = new MockNavigationContainer();
+
+ refHolder.current = firstContainer;
+ instrumentation.registerNavigationContainer(refHolder);
+
+ refHolder.current = secondContainer;
+ instrumentation.registerNavigationContainer(refHolder);
+
+ expect(RN_GLOBAL_OBJ.__sentry_rn_v5_registered).toBe(true);
+
+ expect(firstContainer.addListener).toHaveBeenCalledTimes(2);
+ expect(firstContainer.addListener).toHaveBeenNthCalledWith(1, '__unsafe_action__', expect.any(Function));
+ expect(firstContainer.addListener).toHaveBeenNthCalledWith(2, 'state', expect.any(Function));
+
+ expect(secondContainer.addListener).toHaveBeenCalledTimes(2);
+ expect(secondContainer.addListener).toHaveBeenNthCalledWith(1, '__unsafe_action__', expect.any(Function));
+ expect(secondContainer.addListener).toHaveBeenNthCalledWith(2, 'state', expect.any(Function));
+ });
+
+ test('does not register navigation container if received a new holder with the same reference', () => {
+ const instrumentation = reactNavigationIntegration();
+ const container = new MockNavigationContainer();
+
+ instrumentation.registerNavigationContainer({
+ current: container,
+ });
+
+ instrumentation.registerNavigationContainer({
+ current: container,
+ });
+
+ expect(RN_GLOBAL_OBJ.__sentry_rn_v5_registered).toBe(true);
+
+ expect(container.addListener).toHaveBeenCalledTimes(2);
+ expect(container.addListener).toHaveBeenNthCalledWith(1, '__unsafe_action__', expect.any(Function));
+ expect(container.addListener).toHaveBeenNthCalledWith(2, 'state', expect.any(Function));
});
test('works if routing instrumentation setup is after navigation registration', async () => {
@@ -367,6 +429,66 @@ describe('ReactNavigationInstrumentation', () => {
expect(mockTransaction['_sampled']).not.toBe(false);
});
+
+ test('after all setup registers for runApplication calls', async () => {
+ const { mockedGetAppRegistryIntegration, mockedOnRunApplication } = mockAppRegistryIntegration();
+
+ const instrumentation = reactNavigationIntegration();
+
+ const mockNavigationContainer = new MockNavigationContainer();
+ instrumentation.registerNavigationContainer(mockNavigationContainer);
+
+ instrumentation.afterAllSetup(client);
+ await jest.runOnlyPendingTimersAsync();
+
+ expect(getActiveSpan()).toBeUndefined();
+ expect(mockedGetAppRegistryIntegration).toHaveBeenCalledOnce();
+ expect(mockedOnRunApplication).toHaveBeenCalledOnce();
+
+ const runApplicationCallback = mockedOnRunApplication.mock.calls[0][0];
+ runApplicationCallback();
+
+ const span = getActiveSpan();
+ expect(span).toBeDefined();
+ expect(spanToJSON(span)).toEqual(
+ expect.objectContaining({
+ description: DEFAULT_NAVIGATION_SPAN_NAME,
+ op: 'navigation',
+ }),
+ );
+ });
+
+ test('runApplication calls are ignored when the initial state is not handled', async () => {
+ // This avoid starting a new navigation span when the application run was called before
+ // before the first navigation container is registered.
+
+ const { mockedGetAppRegistryIntegration, mockedOnRunApplication } = mockAppRegistryIntegration();
+
+ reactNavigationIntegration().afterAllSetup(client);
+ await jest.runOnlyPendingTimersAsync(); // Flushes the initial navigation span
+
+ expect(getActiveSpan()).toBeUndefined();
+ expect(mockedGetAppRegistryIntegration).toHaveBeenCalledOnce();
+ expect(mockedOnRunApplication).toHaveBeenCalledOnce();
+
+ const runApplicationCallback = mockedOnRunApplication.mock.calls[0][0];
+ runApplicationCallback();
+
+ const span = getActiveSpan();
+ expect(span).toBeUndefined();
+ });
+
+ test('handles graceful missing app registry integration', async () => {
+ // This avoid starting a new navigation span when the application run was called before
+ // before the first navigation container is registered.
+
+ const { mockedGetAppRegistryIntegration, mockedOnRunApplication } = mockAppRegistryIntegration();
+ mockedOnRunApplication.mockReturnValue(undefined);
+
+ reactNavigationIntegration().afterAllSetup(client);
+
+ expect(mockedGetAppRegistryIntegration).toHaveBeenCalledOnce();
+ });
});
describe('options', () => {
@@ -457,13 +579,170 @@ describe('ReactNavigationInstrumentation', () => {
});
});
+ [true, false].forEach(useDispatchedActionData => {
+ describe(`test actions which should not create a navigation span when useDispatchedActionData is ${useDispatchedActionData}`, () => {
+ beforeEach(async () => {
+ setupTestClient({ useDispatchedActionData });
+ await jest.runOnlyPendingTimers(); // Flushes the initial navigation span
+ client.event = undefined;
+ });
+
+ test(`noop does ${useDispatchedActionData ? 'not' : ''}create a navigation span`, async () => {
+ mockNavigation.emitWithStateChange({
+ data: {
+ action: {
+ type: 'UNKNOWN',
+ },
+ noop: true,
+ stack: undefined,
+ },
+ });
+ await jest.runOnlyPendingTimersAsync();
+ await client.flush();
+
+ expect(client.event === undefined).toBe(useDispatchedActionData);
+ });
+
+ test.each(['PRELOAD', 'SET_PARAMS', 'OPEN_DRAWER', 'CLOSE_DRAWER', 'TOGGLE_DRAWER'])(
+ `%s does ${useDispatchedActionData ? 'not' : ''}create a navigation span`,
+ async actionType => {
+ mockNavigation.emitWithStateChange({
+ data: {
+ action: {
+ type: actionType,
+ },
+ noop: false,
+ stack: undefined,
+ },
+ });
+ await jest.runOnlyPendingTimersAsync();
+ await client.flush();
+
+ expect(client.event === undefined).toBe(useDispatchedActionData);
+ },
+ );
+ });
+ });
+
+ test('noop does not remove the previous navigation span from scope', async () => {
+ setupTestClient({ useDispatchedActionData: true });
+ await jest.runOnlyPendingTimers(); // Flushes the initial navigation span
+
+ mockNavigation.emitNavigationWithoutStateChange();
+ const activeSpan = getActiveSpan();
+
+ mockNavigation.emitWithoutStateChange({
+ data: {
+ action: {
+ type: 'UNKNOWN',
+ },
+ noop: true,
+ stack: undefined,
+ },
+ });
+
+ expect(getActiveSpan()).toBe(activeSpan);
+ });
+
+ test.each(['PRELOAD', 'SET_PARAMS', 'OPEN_DRAWER', 'CLOSE_DRAWER', 'TOGGLE_DRAWER'])(
+ '%s does not remove the previous navigation span from scope',
+ async actionType => {
+ setupTestClient({ useDispatchedActionData: true });
+ await jest.runOnlyPendingTimers(); // Flushes the initial navigation span
+
+ mockNavigation.emitNavigationWithoutStateChange();
+ const activeSpan = getActiveSpan();
+
+ mockNavigation.emitWithoutStateChange({
+ data: {
+ action: {
+ type: actionType,
+ },
+ noop: false,
+ stack: undefined,
+ },
+ });
+
+ expect(getActiveSpan()).toBe(activeSpan);
+ },
+ );
+
+ describe('setCurrentRoute', () => {
+ let mockSetCurrentRoute: jest.Mock;
+
+ beforeEach(() => {
+ mockSetCurrentRoute = jest.fn();
+ const rnTracingIntegration = reactNativeTracingIntegration();
+ rnTracingIntegration.setCurrentRoute = mockSetCurrentRoute;
+
+ const rNavigation = reactNavigationIntegration({
+ routeChangeTimeoutMs: 200,
+ });
+ mockNavigation = createMockNavigationAndAttachTo(rNavigation);
+
+ const options = getDefaultTestClientOptions({
+ enableNativeFramesTracking: false,
+ enableStallTracking: false,
+ tracesSampleRate: 1.0,
+ integrations: [rNavigation, rnTracingIntegration],
+ enableAppStartTracking: false,
+ });
+
+ client = new TestClient(options);
+ setCurrentClient(client);
+ client.init();
+
+ jest.runOnlyPendingTimers();
+ });
+
+ test('setCurrentRoute is called with route name after navigation', async () => {
+ expect(mockSetCurrentRoute).toHaveBeenCalledWith('Initial Screen');
+
+ mockSetCurrentRoute.mockClear();
+ mockNavigation.navigateToNewScreen();
+ jest.runOnlyPendingTimers();
+
+ expect(mockSetCurrentRoute).toHaveBeenCalledWith('New Screen');
+
+ mockSetCurrentRoute.mockClear();
+ mockNavigation.navigateToSecondScreen();
+ jest.runOnlyPendingTimers();
+
+ expect(mockSetCurrentRoute).toHaveBeenCalledWith('Second Screen');
+
+ mockSetCurrentRoute.mockClear();
+ mockNavigation.navigateToInitialScreen();
+ jest.runOnlyPendingTimers();
+
+ expect(mockSetCurrentRoute).toHaveBeenCalledWith('Initial Screen');
+ });
+
+ test('setCurrentRoute is not called when navigation is cancelled', async () => {
+ mockSetCurrentRoute.mockClear();
+ mockNavigation.emitCancelledNavigation();
+ jest.runOnlyPendingTimers();
+
+ expect(mockSetCurrentRoute).not.toHaveBeenCalled();
+ });
+
+ test('setCurrentRoute is not called when navigation finishes', async () => {
+ mockSetCurrentRoute.mockClear();
+ mockNavigation.finishAppStartNavigation();
+ jest.runOnlyPendingTimers();
+
+ expect(mockSetCurrentRoute).not.toHaveBeenCalled();
+ });
+ });
+
function setupTestClient(
setupOptions: {
beforeSpanStart?: (options: StartSpanOptions) => StartSpanOptions;
+ useDispatchedActionData?: boolean;
} = {},
) {
const rNavigation = reactNavigationIntegration({
routeChangeTimeoutMs: 200,
+ useDispatchedActionData: setupOptions.useDispatchedActionData,
});
mockNavigation = createMockNavigationAndAttachTo(rNavigation);
diff --git a/packages/core/test/tracing/reactnavigation.ttid.test.tsx b/packages/core/test/tracing/reactnavigation.ttid.test.tsx
index a0245cff12..68d0f3bb91 100644
--- a/packages/core/test/tracing/reactnavigation.ttid.test.tsx
+++ b/packages/core/test/tracing/reactnavigation.ttid.test.tsx
@@ -1,5 +1,5 @@
import type { Scope, Span, SpanJSON, TransactionEvent, Transport } from '@sentry/core';
-import { timestampInSeconds } from '@sentry/core';
+import { getActiveSpan, spanToJSON, timestampInSeconds } from '@sentry/core';
import * as TestRenderer from '@testing-library/react-native'
import * as React from "react";
@@ -16,13 +16,12 @@ import { startSpanManual } from '../../src/js';
import { TimeToFullDisplay, TimeToInitialDisplay } from '../../src/js/tracing';
import { _setAppStartEndTimestampMs } from '../../src/js/tracing/integrations/appStart';
import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION, SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../../src/js/tracing/origin';
+import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span';
import { isHermesEnabled, notWeb } from '../../src/js/utils/environment';
-import { createSentryFallbackEventEmitter } from '../../src/js/utils/sentryeventemitterfallback';
import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide';
import { MOCK_DSN } from '../mockDsn';
-import { secondInFutureTimestampMs } from '../testutils';
-import type { MockedSentryEventEmitterFallback } from '../utils/mockedSentryeventemitterfallback';
-import { emitNativeFullDisplayEvent, emitNativeInitialDisplayEvent } from './mockedtimetodisplaynative';
+import { nowInSeconds, secondInFutureTimestampMs } from '../testutils';
+import { mockRecordedTimeToDisplay } from './mockedtimetodisplaynative';
import { createMockNavigationAndAttachTo } from './reactnavigationutils';
const SCOPE_SPAN_FIELD = '_sentrySpan';
@@ -32,7 +31,6 @@ type ScopeWithMaybeSpan = Scope & {
};
describe('React Navigation - TTID', () => {
- let mockedEventEmitter: MockedSentryEventEmitterFallback;
let transportSendMock: jest.Mock, Parameters>;
let mockedNavigation: ReturnType;
const mockedAppStartTimeSeconds: number = timestampInSeconds();
@@ -51,9 +49,6 @@ describe('React Navigation - TTID', () => {
});
_setAppStartEndTimestampMs(mockedAppStartTimeSeconds * 1000);
- mockedEventEmitter = mockedSentryEventEmitter.createMockedSentryFallbackEventEmitter();
- (createSentryFallbackEventEmitter as jest.Mock).mockReturnValue(mockedEventEmitter);
-
const sut = createTestedInstrumentation({ enableTimeToInitialDisplay: true });
transportSendMock = initSentry(sut).transportSendMock;
@@ -68,7 +63,7 @@ describe('React Navigation - TTID', () => {
jest.runOnlyPendingTimers(); // Flush app start transaction
mockedNavigation.navigateToNewScreen();
- mockedEventEmitter.emitNewFrameEvent();
+ mockAutomaticTimeToDisplay();
jest.runOnlyPendingTimers(); // Flush ttid transaction
const transaction = getLastTransaction(transportSendMock);
@@ -80,6 +75,7 @@ describe('React Navigation - TTID', () => {
data: {
'sentry.op': 'ui.load.initial_display',
'sentry.origin': SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY,
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
description: 'New Screen initial display',
op: 'ui.load.initial_display',
@@ -97,8 +93,8 @@ describe('React Navigation - TTID', () => {
jest.runOnlyPendingTimers(); // Flush app start transaction
mockedNavigation.navigateToNewScreen();
+ mockAutomaticTimeToDisplay();
(Sentry.getCurrentScope() as ScopeWithMaybeSpan)[SCOPE_SPAN_FIELD] = undefined;
- mockedEventEmitter.emitNewFrameEvent();
jest.runOnlyPendingTimers(); // Flush transaction
const transaction = getLastTransaction(transportSendMock);
@@ -110,6 +106,7 @@ describe('React Navigation - TTID', () => {
data: {
'sentry.op': 'ui.load.initial_display',
'sentry.origin': SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY,
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
description: 'New Screen initial display',
op: 'ui.load.initial_display',
@@ -133,8 +130,8 @@ describe('React Navigation - TTID', () => {
jest.runOnlyPendingTimers(); // Flush app start transaction
mockedNavigation.navigateToNewScreen();
+ mockAutomaticTimeToDisplay();
(Sentry.getCurrentScope() as ScopeWithMaybeSpan)[SCOPE_SPAN_FIELD] = startSpanManual({ name: 'test' }, s => s);
- mockedEventEmitter.emitNewFrameEvent();
jest.runOnlyPendingTimers(); // Flush transaction
const transaction = getLastTransaction(transportSendMock);
@@ -146,6 +143,7 @@ describe('React Navigation - TTID', () => {
data: {
'sentry.op': 'ui.load.initial_display',
'sentry.origin': SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY,
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
description: 'New Screen initial display',
op: 'ui.load.initial_display',
@@ -169,7 +167,7 @@ describe('React Navigation - TTID', () => {
jest.runOnlyPendingTimers(); // Flush app start transaction
mockedNavigation.navigateToNewScreen();
- mockedEventEmitter.emitNewFrameEvent();
+ mockAutomaticTimeToDisplay();
jest.runOnlyPendingTimers(); // Flush ttid transaction
const transaction = getLastTransaction(transportSendMock);
@@ -190,7 +188,7 @@ describe('React Navigation - TTID', () => {
jest.runOnlyPendingTimers(); // Flush app start transaction
mockedNavigation.navigateToNewScreen();
- mockedEventEmitter.emitNewFrameEvent();
+ mockAutomaticTimeToDisplay();
jest.runOnlyPendingTimers(); // Flush ttid transaction
const transaction = getLastTransaction(transportSendMock);
@@ -203,6 +201,7 @@ describe('React Navigation - TTID', () => {
'sentry.op': 'navigation.processing',
'sentry.origin': SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION,
'sentry.source': 'custom',
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
description: 'Navigation dispatch to screen New Screen mounted',
op: 'navigation.processing',
@@ -218,7 +217,7 @@ describe('React Navigation - TTID', () => {
test('should add processing navigation span for application start up', () => {
mockedNavigation.finishAppStartNavigation();
- mockedEventEmitter.emitNewFrameEvent();
+ mockAutomaticTimeToDisplay();
jest.runOnlyPendingTimers(); // Flush ttid transaction
const transaction = getLastTransaction(transportSendMock);
@@ -231,6 +230,7 @@ describe('React Navigation - TTID', () => {
'sentry.op': 'navigation.processing',
'sentry.origin': SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION,
'sentry.source': 'custom',
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
description: 'Navigation dispatch to screen Initial Screen mounted',
op: 'navigation.processing',
@@ -246,7 +246,7 @@ describe('React Navigation - TTID', () => {
test('should add ttid span for application start up', () => {
mockedNavigation.finishAppStartNavigation();
- mockedEventEmitter.emitNewFrameEvent();
+ mockAutomaticTimeToDisplay();
jest.runOnlyPendingTimers(); // Flush ttid transaction
const transaction = getLastTransaction(transportSendMock);
@@ -261,6 +261,7 @@ describe('React Navigation - TTID', () => {
data: {
'sentry.op': 'ui.load.initial_display',
'sentry.origin': SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY,
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
description: 'Initial Screen initial display',
op: 'ui.load.initial_display',
@@ -276,10 +277,16 @@ describe('React Navigation - TTID', () => {
test('should add ttfd span for application start up', () => {
mockedNavigation.finishAppStartNavigation();
- mockedEventEmitter.emitNewFrameEvent();
TestRenderer.render();
- emitNativeFullDisplayEvent();
+ mockRecordedTimeToDisplay({
+ ttidNavigation: {
+ [spanToJSON(getActiveSpan()!).span_id!]: nowInSeconds(),
+ },
+ ttfd: {
+ [spanToJSON(getActiveSpan()!).span_id!]: nowInSeconds(),
+ },
+ });
jest.runOnlyPendingTimers(); // Flush ttid transaction
@@ -295,6 +302,7 @@ describe('React Navigation - TTID', () => {
data: {
'sentry.op': 'ui.load.full_display',
'sentry.origin': SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY,
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
description: 'Time To Full Display',
op: 'ui.load.full_display',
@@ -310,7 +318,7 @@ describe('React Navigation - TTID', () => {
test('should add ttid measurement for application start up', () => {
mockedNavigation.finishAppStartNavigation();
- mockedEventEmitter.emitNewFrameEvent();
+ mockAutomaticTimeToDisplay();
jest.runOnlyPendingTimers(); // Flush ttid transaction
const transaction = getLastTransaction(transportSendMock);
@@ -334,7 +342,7 @@ describe('React Navigation - TTID', () => {
test('ttid span duration and measurement should equal for application start up', () => {
mockedNavigation.finishAppStartNavigation();
- mockedEventEmitter.emitNewFrameEvent();
+ mockAutomaticTimeToDisplay();
jest.runOnlyPendingTimers(); // Flush ttid transaction
const transaction = getLastTransaction(transportSendMock);
@@ -343,12 +351,46 @@ describe('React Navigation - TTID', () => {
expect(getSpanDurationMs(transaction, 'ui.load.initial_display')).toEqual(transaction.measurements?.time_to_initial_display?.value);
});
+ test('ttfd span duration and measurement should equal ttid from ttfd is called earlier than ttid', () => {
+ jest.runOnlyPendingTimers(); // Flush app start transaction
+
+ mockedNavigation.navigateToNewScreen();
+ TestRenderer.render();
+ mockRecordedTimeToDisplay({
+ ttidNavigation: {
+ [spanToJSON(getActiveSpan()!).span_id!]: timestampInSeconds(),
+ },
+ ttfd: {
+ [spanToJSON(getActiveSpan()!).span_id!]: timestampInSeconds() - 1,
+ },
+ });
+
+ jest.runOnlyPendingTimers(); // Flush navigation transaction
+
+ const transaction = getLastTransaction(transportSendMock);
+ const ttfdSpanDuration = getSpanDurationMs(transaction, 'ui.load.full_display');
+ const ttidSpanDuration = getSpanDurationMs(transaction, 'ui.load.initial_display');
+ expect(ttfdSpanDuration).toBeDefined();
+ expect(ttidSpanDuration).toBeDefined();
+ expect(ttfdSpanDuration).toEqual(ttidSpanDuration);
+
+ expect(transaction.measurements?.time_to_full_display?.value).toBeDefined();
+ expect(transaction.measurements?.time_to_initial_display?.value).toBeDefined();
+ expect(transaction.measurements?.time_to_full_display?.value).toEqual(transaction.measurements?.time_to_initial_display?.value);
+ });
+
test('ttfd span duration and measurement should equal for application start up', () => {
mockedNavigation.finishAppStartNavigation();
- mockedEventEmitter.emitNewFrameEvent();
TestRenderer.render();
- emitNativeFullDisplayEvent();
+ mockRecordedTimeToDisplay({
+ ttidNavigation: {
+ [spanToJSON(getActiveSpan()!).span_id!]: timestampInSeconds(),
+ },
+ ttfd: {
+ [spanToJSON(getActiveSpan()!).span_id!]: timestampInSeconds(),
+ },
+ });
jest.runOnlyPendingTimers(); // Flush ttid transaction
@@ -359,6 +401,7 @@ describe('React Navigation - TTID', () => {
});
test('idle transaction should cancel the ttid span if new frame not received', () => {
+ jest.runOnlyPendingTimers(); // Flush app start transaction
mockedNavigation.navigateToNewScreen();
jest.runOnlyPendingTimers(); // Flush ttid transaction
@@ -366,18 +409,9 @@ describe('React Navigation - TTID', () => {
expect(transaction).toEqual(
expect.objectContaining({
type: 'transaction',
- spans: expect.arrayContaining([
+ spans: expect.not.arrayContaining([
expect.objectContaining>({
- data: {
- 'sentry.op': 'ui.load.initial_display',
- 'sentry.origin': SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY,
- },
- description: 'New Screen initial display',
op: 'ui.load.initial_display',
- origin: SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY,
- status: 'cancelled',
- start_timestamp: transaction.start_timestamp,
- timestamp: expect.any(Number),
}),
]),
}),
@@ -388,11 +422,11 @@ describe('React Navigation - TTID', () => {
jest.runOnlyPendingTimers(); // Flush app start transaction
mockedNavigation.navigateToNewScreen();
- mockedEventEmitter.emitNewFrameEvent();
+ mockAutomaticTimeToDisplay();
jest.runOnlyPendingTimers(); // Flush transaction
mockedNavigation.navigateToInitialScreen();
- mockedEventEmitter.emitNewFrameEvent();
+ mockAutomaticTimeToDisplay();
jest.runOnlyPendingTimers(); // Flush transaction
const transaction = getLastTransaction(transportSendMock);
@@ -408,11 +442,11 @@ describe('React Navigation - TTID', () => {
jest.runOnlyPendingTimers(); // Flush app start transaction
mockedNavigation.navigateToNewScreen();
- mockedEventEmitter.emitNewFrameEvent();
+ mockAutomaticTimeToDisplay();
jest.runOnlyPendingTimers(); // Flush transaction
mockedNavigation.navigateToInitialScreen();
- mockedEventEmitter.emitNewFrameEvent();
+ mockAutomaticTimeToDisplay();
const artificialSpan = Sentry.startInactiveSpan({
name: 'Artificial span to ensure back navigation transaction is not empty',
});
@@ -447,9 +481,13 @@ describe('React Navigation - TTID', () => {
mockedNavigation.navigateToNewScreen();
const timeToDisplayComponent = TestRenderer.render();
- mockedEventEmitter.emitNewFrameEvent();
+ mockAutomaticTimeToDisplay();
timeToDisplayComponent.update();
- emitNativeInitialDisplayEvent(manualInitialDisplayEndTimestampMs);
+ mockRecordedTimeToDisplay({
+ ttid: {
+ [spanToJSON(getActiveSpan()!).span_id!]: manualInitialDisplayEndTimestampMs / 1_000,
+ },
+ });
jest.runOnlyPendingTimers(); // Flush transaction
@@ -473,40 +511,6 @@ describe('React Navigation - TTID', () => {
}),
);
});
-
- test('auto initial display api overwrites manual api if manual not initialized on time', () => {
- const autoInitialDisplayEndTimestampMs = timestampInSeconds();
-
- jest.runOnlyPendingTimers(); // Flush app start transaction
- mockedNavigation.navigateToNewScreen();
- mockedEventEmitter.emitNewFrameEvent(autoInitialDisplayEndTimestampMs);
-
- // Initialized too late auto instrumentation finished before manual
- TestRenderer.render();
- emitNativeInitialDisplayEvent(secondInFutureTimestampMs());
-
- jest.runOnlyPendingTimers(); // Flush transaction
-
- const transaction = getLastTransaction(transportSendMock);
- expect(transaction).toEqual(
- expect.objectContaining({
- type: 'transaction',
- transaction: 'New Screen',
- spans: expect.arrayContaining([
- expect.objectContaining>({
- op: 'ui.load.initial_display',
- timestamp: autoInitialDisplayEndTimestampMs,
- }),
- ]),
- measurements: expect.objectContaining['measurements']>({
- time_to_initial_display: {
- value: expect.any(Number),
- unit: 'millisecond',
- },
- }),
- }),
- );
- });
});
describe('ttid disabled', () => {
@@ -579,6 +583,55 @@ describe('React Navigation - TTID', () => {
});
});
+ describe('ttid for preloaded/seen routes', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ (notWeb as jest.Mock).mockReturnValue(true);
+ (isHermesEnabled as jest.Mock).mockReturnValue(true);
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('should add ttid span and measurement for already seen route', () => {
+ const sut = createTestedInstrumentation({
+ enableTimeToInitialDisplay: true,
+ ignoreEmptyBackNavigationTransactions: false,
+ enableTimeToInitialDisplayForPreloadedRoutes: true,
+ });
+ transportSendMock = initSentry(sut).transportSendMock;
+
+ mockedNavigation = createMockNavigationAndAttachTo(sut);
+
+ jest.runOnlyPendingTimers(); // Flush app start transaction
+ mockedNavigation.navigateToNewScreen();
+ jest.runOnlyPendingTimers(); // Flush navigation transaction
+ mockedNavigation.navigateToInitialScreen();
+ mockAutomaticTimeToDisplay();
+ jest.runOnlyPendingTimers(); // Flush navigation transaction
+
+ const transaction = getLastTransaction(transportSendMock);
+ expect(transaction).toEqual(
+ expect.objectContaining({
+ type: 'transaction',
+ spans: expect.arrayContaining([
+ expect.objectContaining>({
+ op: 'ui.load.initial_display',
+ description: 'Initial Screen initial display',
+ }),
+ ]),
+ measurements: expect.objectContaining['measurements']>({
+ time_to_initial_display: {
+ value: expect.any(Number),
+ unit: 'millisecond',
+ },
+ }),
+ }),
+ );
+ });
+ });
+
function getSpanDurationMs(transaction: TransactionEvent, op: string): number | undefined {
const ttidSpan = transaction.spans?.find(span => span.op === op);
if (!ttidSpan) {
@@ -593,10 +646,13 @@ describe('React Navigation - TTID', () => {
return (spanJSON.timestamp - spanJSON.start_timestamp) * 1000;
}
- function createTestedInstrumentation(options?: { enableTimeToInitialDisplay?: boolean }) {
+ function createTestedInstrumentation(options?: {
+ enableTimeToInitialDisplay?: boolean
+ enableTimeToInitialDisplayForPreloadedRoutes?: boolean
+ ignoreEmptyBackNavigationTransactions?: boolean
+ }) {
const sut = Sentry.reactNavigationIntegration({
...options,
- ignoreEmptyBackNavigationTransactions: true, // default true
});
return sut;
}
@@ -607,6 +663,14 @@ describe('React Navigation - TTID', () => {
}
});
+function mockAutomaticTimeToDisplay(): void {
+ mockRecordedTimeToDisplay({
+ ttidNavigation: {
+ [spanToJSON(getActiveSpan()!).span_id!]: nowInSeconds(),
+ },
+ });
+}
+
function initSentry(sut: ReturnType): {
transportSendMock: jest.Mock, Parameters>;
} {
@@ -619,6 +683,7 @@ function initSentry(sut: ReturnType):
integrations: [
sut,
Sentry.reactNativeTracingIntegration(),
+ Sentry.timeToDisplayIntegration(),
],
transport: () => ({
send: transportSendMock.mockResolvedValue({}),
diff --git a/packages/core/test/tracing/reactnavigationutils.ts b/packages/core/test/tracing/reactnavigationutils.ts
index 3aba609d13..bbc4576859 100644
--- a/packages/core/test/tracing/reactnavigationutils.ts
+++ b/packages/core/test/tracing/reactnavigationutils.ts
@@ -1,17 +1,24 @@
import type { NavigationRoute, reactNavigationIntegration } from '../../src/js/tracing/reactnavigation';
+import type { UnsafeAction } from '../../src/js/vendor/react-navigation/types';
+
+const navigationAction: UnsafeAction = {
+ data: {
+ action: {
+ type: 'NAVIGATE',
+ },
+ noop: false,
+ stack: undefined,
+ },
+};
export function createMockNavigationAndAttachTo(sut: ReturnType) {
const mockedNavigationContained = mockNavigationContainer();
const mockedNavigation = {
emitCancelledNavigation: () => {
- mockedNavigationContained.listeners['__unsafe_action__']({
- // this object is not used by the instrumentation
- });
+ mockedNavigationContained.listeners['__unsafe_action__'](navigationAction);
},
navigateToNewScreen: () => {
- mockedNavigationContained.listeners['__unsafe_action__']({
- // this object is not used by the instrumentation
- });
+ mockedNavigationContained.listeners['__unsafe_action__'](navigationAction);
mockedNavigationContained.currentRoute = {
key: 'new_screen',
name: 'New Screen',
@@ -21,9 +28,7 @@ export function createMockNavigationAndAttachTo(sut: ReturnType {
- mockedNavigationContained.listeners['__unsafe_action__']({
- // this object is not used by the instrumentation
- });
+ mockedNavigationContained.listeners['__unsafe_action__'](navigationAction);
mockedNavigationContained.currentRoute = {
key: 'second_screen',
name: 'Second Screen',
@@ -33,9 +38,7 @@ export function createMockNavigationAndAttachTo(sut: ReturnType {
- mockedNavigationContained.listeners['__unsafe_action__']({
- // this object is not used by the instrumentation
- });
+ mockedNavigationContained.listeners['__unsafe_action__'](navigationAction);
mockedNavigationContained.currentRoute = {
key: 'initial_screen',
name: 'Initial Screen',
@@ -53,6 +56,18 @@ export function createMockNavigationAndAttachTo(sut: ReturnType {
+ mockedNavigationContained.listeners['__unsafe_action__'](navigationAction);
+ },
+ emitWithoutStateChange: (action: UnsafeAction) => {
+ mockedNavigationContained.listeners['__unsafe_action__'](action);
+ },
+ emitWithStateChange: (action: UnsafeAction) => {
+ mockedNavigationContained.listeners['__unsafe_action__'](action);
+ mockedNavigationContained.listeners['state']({
+ // this object is not used by the instrumentation
+ });
+ },
};
sut.registerNavigationContainer(mockRef(mockedNavigationContained));
diff --git a/packages/core/test/tracing/timetodisplay.test.tsx b/packages/core/test/tracing/timetodisplay.test.tsx
index bfdbd3baa5..43413abcbc 100644
--- a/packages/core/test/tracing/timetodisplay.test.tsx
+++ b/packages/core/test/tracing/timetodisplay.test.tsx
@@ -1,11 +1,15 @@
-import { getActiveSpan, getCurrentScope, getGlobalScope, getIsolationScope, getSpanDescendants, logger , setCurrentClient, spanToJSON, startSpanManual} from '@sentry/core';
+import { getCurrentScope, getGlobalScope, getIsolationScope, logger , setCurrentClient, spanToJSON, startSpanManual } from '@sentry/core';
jest.spyOn(logger, 'warn');
+import * as mockWrapper from '../mockWrapper';
+jest.mock('../../src/js/wrapper', () => mockWrapper);
+
import * as mockedtimetodisplaynative from './mockedtimetodisplaynative';
jest.mock('../../src/js/tracing/timetodisplaynative', () => mockedtimetodisplaynative);
import { isTurboModuleEnabled } from '../../src/js/utils/environment';
jest.mock('../../src/js/utils/environment', () => ({
+ isWeb: jest.fn().mockReturnValue(false),
isTurboModuleEnabled: jest.fn().mockReturnValue(false),
}));
@@ -13,12 +17,15 @@ import type { Event, Measurements, Span, SpanJSON} from '@sentry/core';
import * as React from "react";
import * as TestRenderer from 'react-test-renderer';
+import { timeToDisplayIntegration } from '../../src/js/tracing/integrations/timeToDisplayIntegration';
import { SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../../src/js/tracing/origin';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../src/js/tracing/semanticAttributes';
+import { SPAN_THREAD_NAME , SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span';
import { startTimeToFullDisplaySpan, startTimeToInitialDisplaySpan, TimeToFullDisplay, TimeToInitialDisplay } from '../../src/js/tracing/timetodisplay';
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
-import { secondAgoTimestampMs, secondInFutureTimestampMs } from '../testutils';
-import { emitNativeFullDisplayEvent, emitNativeInitialDisplayEvent } from './mockedtimetodisplaynative';
+import { nowInSeconds, secondAgoTimestampMs, secondInFutureTimestampMs } from '../testutils';
+
+const { mockRecordedTimeToDisplay, getMockedOnDrawReportedProps, clearMockedOnDrawReportedProps } = mockedtimetodisplaynative;
jest.useFakeTimers({advanceTimers: true});
@@ -26,6 +33,7 @@ describe('TimeToDisplay', () => {
let client: TestClient;
beforeEach(() => {
+ clearMockedOnDrawReportedProps();
getCurrentScope().clear();
getIsolationScope().clear();
getGlobalScope().clear();
@@ -33,7 +41,13 @@ describe('TimeToDisplay', () => {
const options = getDefaultTestClientOptions({
tracesSampleRate: 1.0,
});
- client = new TestClient(options);
+ client = new TestClient({
+ ...options,
+ integrations: [
+ ...options.integrations,
+ timeToDisplayIntegration(),
+ ],
+ });
setCurrentClient(client);
client.init();
});
@@ -43,20 +57,21 @@ describe('TimeToDisplay', () => {
});
test('creates manual initial display', async () => {
- const [testSpan, activeSpan] = startSpanManual(
+ startSpanManual(
{
name: 'Root Manual Span',
startTime: secondAgoTimestampMs(),
},
(activeSpan: Span | undefined) => {
- const testSpan = startTimeToInitialDisplaySpan();
+ startTimeToInitialDisplaySpan();
TestRenderer.create();
-
- emitNativeInitialDisplayEvent();
+ mockRecordedTimeToDisplay({
+ ttid: {
+ [spanToJSON(activeSpan!).span_id!]: nowInSeconds(),
+ },
+ });
activeSpan?.end();
-
- return [testSpan, activeSpan];
},
);
@@ -64,83 +79,33 @@ describe('TimeToDisplay', () => {
await client.flush();
expectInitialDisplayMeasurementOnSpan(client.event!);
- expectFinishedInitialDisplaySpan(testSpan, activeSpan);
- expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp);
+ expectFinishedInitialDisplaySpan(client.event!);
+ expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id);
});
test('creates manual full display', async () => {
- const [testSpan, activeSpan] = startSpanManual(
+ startSpanManual(
{
name: 'Root Manual Span',
startTime: secondAgoTimestampMs(),
},
(activeSpan: Span | undefined) => {
startTimeToInitialDisplaySpan();
- const testSpan = startTimeToFullDisplaySpan();
+ startTimeToFullDisplaySpan();
TestRenderer.create();
- emitNativeInitialDisplayEvent();
-
TestRenderer.create();
- emitNativeFullDisplayEvent();
-
- activeSpan?.end();
- return [testSpan, activeSpan];
- },
- );
-
- await jest.runOnlyPendingTimersAsync();
- await client.flush();
-
- expectFullDisplayMeasurementOnSpan(client.event!);
- expectFinishedFullDisplaySpan(testSpan, activeSpan);
- expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp);
- });
-
- test('creates initial display span on first component render', async () => {
- const [testSpan, activeSpan] = startSpanManual(
- {
- name: 'Root Manual Span',
- startTime: secondAgoTimestampMs(),
- },
- (activeSpan: Span | undefined) => {
- const renderer = TestRenderer.create();
- const testSpan = getInitialDisplaySpan(activeSpan);
-
- renderer.update();
- emitNativeInitialDisplayEvent();
- activeSpan?.end();
- return [testSpan, activeSpan];
- },
- );
-
- await jest.runOnlyPendingTimersAsync();
- await client.flush();
-
- expectInitialDisplayMeasurementOnSpan(client.event!);
- expectFinishedInitialDisplaySpan(testSpan, activeSpan);
- expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp);
- });
-
- test('creates full display span on first component render', async () => {
- const [testSpan, activeSpan] = startSpanManual(
- {
- name: 'Root Manual Span',
- startTime: secondAgoTimestampMs(),
- },
- (activeSpan: Span | undefined) => {
- TestRenderer.create();
- emitNativeInitialDisplayEvent();
-
- const renderer = TestRenderer.create();
- const testSpan = getFullDisplaySpan(getActiveSpan());
-
- renderer.update();
- emitNativeFullDisplayEvent();
+ mockRecordedTimeToDisplay({
+ ttid: {
+ [spanToJSON(activeSpan!).span_id!]: nowInSeconds(),
+ },
+ ttfd: {
+ [spanToJSON(activeSpan!).span_id!]: nowInSeconds(),
+ },
+ });
activeSpan?.end();
- return [testSpan, activeSpan];
},
);
@@ -148,12 +113,13 @@ describe('TimeToDisplay', () => {
await client.flush();
expectFullDisplayMeasurementOnSpan(client.event!);
- expectFinishedFullDisplaySpan(testSpan, activeSpan);
- expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp);
+ expectFinishedFullDisplaySpan(client.event!);
+ expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id);
+ expect(getMockedOnDrawReportedProps()[1]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id);
});
test('does not create full display when initial display is missing', async () => {
- const [activeSpan] = startSpanManual(
+ startSpanManual(
{
name: 'Root Manual Span',
startTime: secondAgoTimestampMs(),
@@ -162,10 +128,13 @@ describe('TimeToDisplay', () => {
startTimeToFullDisplaySpan();
TestRenderer.create();
- emitNativeFullDisplayEvent();
+ mockRecordedTimeToDisplay({
+ ttfd: {
+ [spanToJSON(activeSpan!).span_id!]: nowInSeconds(),
+ },
+ });
activeSpan?.end();
- return [activeSpan];
},
);
@@ -175,11 +144,13 @@ describe('TimeToDisplay', () => {
expectNoInitialDisplayMeasurementOnSpan(client.event!);
expectNoFullDisplayMeasurementOnSpan(client.event!);
- expectNoTimeToDisplaySpans(activeSpan);
+ expectNoTimeToDisplaySpans(client.event!);
+
+ expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id);
});
test('creates initial display for active span without initial display span', async () => {
- const [activeSpan] = startSpanManual(
+ startSpanManual(
{
name: 'Root Manual Span',
startTime: secondAgoTimestampMs(),
@@ -187,10 +158,13 @@ describe('TimeToDisplay', () => {
(activeSpan: Span | undefined) => {
TestRenderer.create();
- emitNativeInitialDisplayEvent();
+ mockRecordedTimeToDisplay({
+ ttid: {
+ [spanToJSON(activeSpan!).span_id!]: nowInSeconds(),
+ },
+ });
activeSpan?.end();
- return [activeSpan];
},
);
@@ -198,11 +172,12 @@ describe('TimeToDisplay', () => {
await client.flush();
expectInitialDisplayMeasurementOnSpan(client.event!);
- expectFinishedInitialDisplaySpan(getInitialDisplaySpan(activeSpan), activeSpan);
+ expectFinishedInitialDisplaySpan(client.event!);
+ expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id);
});
test('creates full display for active span without full display span', async () => {
- const [activeSpan] = startSpanManual(
+ startSpanManual(
{
name: 'Root Manual Span',
startTime: secondAgoTimestampMs(),
@@ -212,13 +187,18 @@ describe('TimeToDisplay', () => {
startTimeToFullDisplaySpan();
TestRenderer.create();
- emitNativeInitialDisplayEvent();
-
TestRenderer.create();
- emitNativeFullDisplayEvent();
+
+ mockRecordedTimeToDisplay({
+ ttid: {
+ [spanToJSON(activeSpan!).span_id!]: nowInSeconds(),
+ },
+ ttfd: {
+ [spanToJSON(activeSpan!).span_id!]: nowInSeconds(),
+ },
+ });
activeSpan?.end();
- return [activeSpan];
},
);
@@ -226,11 +206,13 @@ describe('TimeToDisplay', () => {
await client.flush();
expectFullDisplayMeasurementOnSpan(client.event!);
- expectFinishedFullDisplaySpan(getFullDisplaySpan(activeSpan), activeSpan);
+ expectFinishedFullDisplaySpan(client.event!);
+ expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id);
+ expect(getMockedOnDrawReportedProps()[1]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id);
});
test('cancels full display spans longer than 30s', async () => {
- const [activeSpan] = startSpanManual(
+ startSpanManual(
{
name: 'Root Manual Span',
startTime: secondAgoTimestampMs(),
@@ -240,23 +222,26 @@ describe('TimeToDisplay', () => {
startTimeToFullDisplaySpan();
TestRenderer.create();
- emitNativeInitialDisplayEvent();
-
TestRenderer.create();
- // native event is not emitted
- jest.advanceTimersByTime(40_000);
+ mockRecordedTimeToDisplay({
+ ttid: {
+ [spanToJSON(activeSpan!).span_id!]: nowInSeconds(),
+ },
+ ttfd: {
+ [spanToJSON(activeSpan!).span_id!]: nowInSeconds() + 40,
+ },
+ });
activeSpan?.end();
- return [activeSpan];
},
);
await jest.runOnlyPendingTimersAsync();
await client.flush();
- expectFinishedInitialDisplaySpan(getInitialDisplaySpan(activeSpan), activeSpan);
- expectDeadlineExceededFullDisplaySpan(getFullDisplaySpan(activeSpan), activeSpan);
+ expectFinishedInitialDisplaySpan(client.event!);
+ expectDeadlineExceededFullDisplaySpan(client.event!);
expectInitialDisplayMeasurementOnSpan(client.event!);
expectFullDisplayMeasurementOnSpan(client.event!);
@@ -267,120 +252,46 @@ describe('TimeToDisplay', () => {
test('full display which ended before initial display is extended to initial display end', async () => {
const fullDisplayEndTimestampMs = secondInFutureTimestampMs();
const initialDisplayEndTimestampMs = secondInFutureTimestampMs() + 500;
- const [initialDisplaySpan, fullDisplaySpan, activeSpan] = startSpanManual(
+ startSpanManual(
{
name: 'Root Manual Span',
startTime: secondAgoTimestampMs(),
},
(activeSpan: Span | undefined) => {
- const initialDisplaySpan = startTimeToInitialDisplaySpan();
- const fullDisplaySpan = startTimeToFullDisplaySpan();
-
- const timeToDisplayComponent = TestRenderer.create(<>>);
- emitNativeFullDisplayEvent(fullDisplayEndTimestampMs);
-
- timeToDisplayComponent.update(<>>);
- emitNativeFullDisplayEvent(fullDisplayEndTimestampMs + 10);
- emitNativeInitialDisplayEvent(initialDisplayEndTimestampMs);
-
- activeSpan?.end();
- return [initialDisplaySpan, fullDisplaySpan, activeSpan];
- },
- );
-
- await jest.runOnlyPendingTimersAsync();
- await client.flush();
-
- expectFinishedInitialDisplaySpan(initialDisplaySpan, activeSpan);
- expectFinishedFullDisplaySpan(fullDisplaySpan, activeSpan);
-
- expectInitialDisplayMeasurementOnSpan(client.event!);
- expectFullDisplayMeasurementOnSpan(client.event!);
-
- expect(spanToJSON(initialDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000);
- expect(spanToJSON(fullDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000);
- });
-
- test('full display which ended before but processed after initial display is extended to initial display end', async () => {
- const fullDisplayEndTimestampMs = secondInFutureTimestampMs();
- const initialDisplayEndTimestampMs = secondInFutureTimestampMs() + 500;
- const [initialDisplaySpan, fullDisplaySpan, activeSpan] = startSpanManual(
- {
- name: 'Root Manual Span',
- startTime: secondAgoTimestampMs(),
- },
- (activeSpan: Span | undefined) => {
- const initialDisplaySpan = startTimeToInitialDisplaySpan();
- const fullDisplaySpan = startTimeToFullDisplaySpan();
+ startTimeToInitialDisplaySpan();
+ startTimeToFullDisplaySpan();
const timeToDisplayComponent = TestRenderer.create(<>>);
- timeToDisplayComponent.update(<>>);
-
- emitNativeInitialDisplayEvent(initialDisplayEndTimestampMs);
- emitNativeFullDisplayEvent(fullDisplayEndTimestampMs);
-
- activeSpan?.end();
- return [initialDisplaySpan, fullDisplaySpan, activeSpan];
- },
- );
-
- await jest.runOnlyPendingTimersAsync();
- await client.flush();
-
- expectFinishedInitialDisplaySpan(initialDisplaySpan, activeSpan);
- expectFinishedFullDisplaySpan(fullDisplaySpan, activeSpan);
-
- expectInitialDisplayMeasurementOnSpan(client.event!);
- expectFullDisplayMeasurementOnSpan(client.event!);
-
- expect(spanToJSON(initialDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000);
- expect(spanToJSON(fullDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000);
- });
-
- test('consequent renders do not update display end', async () => {
- const initialDisplayEndTimestampMs = secondInFutureTimestampMs();
- const fullDisplayEndTimestampMs = secondInFutureTimestampMs() + 500;
- const [initialDisplaySpan, fullDisplaySpan, activeSpan] = startSpanManual(
- {
- name: 'Root Manual Span',
- startTime: secondAgoTimestampMs(),
- },
- (activeSpan: Span | undefined) => {
- const initialDisplaySpan = startTimeToInitialDisplaySpan();
- const fullDisplaySpan = startTimeToFullDisplaySpan();
-
- const timeToDisplayComponent = TestRenderer.create(<>>);
- emitNativeInitialDisplayEvent(initialDisplayEndTimestampMs);
-
- timeToDisplayComponent.update(<>>);
- emitNativeInitialDisplayEvent(fullDisplayEndTimestampMs + 10);
timeToDisplayComponent.update(<>>);
- emitNativeFullDisplayEvent(fullDisplayEndTimestampMs);
- timeToDisplayComponent.update(<>>);
- emitNativeFullDisplayEvent(fullDisplayEndTimestampMs + 20);
+ mockRecordedTimeToDisplay({
+ ttfd: {
+ [spanToJSON(activeSpan!).span_id!]: fullDisplayEndTimestampMs / 1_000,
+ },
+ ttid: {
+ [spanToJSON(activeSpan!).span_id!]: initialDisplayEndTimestampMs / 1_000,
+ },
+ });
activeSpan?.end();
- return [initialDisplaySpan, fullDisplaySpan, activeSpan];
},
);
await jest.runOnlyPendingTimersAsync();
await client.flush();
- expectFinishedInitialDisplaySpan(initialDisplaySpan, activeSpan);
- expectFinishedFullDisplaySpan(fullDisplaySpan, activeSpan);
+ expectFinishedInitialDisplaySpan(client.event!);
+ expectFinishedFullDisplaySpan(client.event!);
expectInitialDisplayMeasurementOnSpan(client.event!);
expectFullDisplayMeasurementOnSpan(client.event!);
- expect(spanToJSON(initialDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000);
- expect(spanToJSON(fullDisplaySpan!).timestamp).toEqual(fullDisplayEndTimestampMs / 1_000);
+ expect(getInitialDisplaySpanJSON(client.event!.spans!)!.timestamp).toEqual(initialDisplayEndTimestampMs / 1_000);
+ expect(getFullDisplaySpanJSON(client.event!.spans!)!.timestamp).toEqual(initialDisplayEndTimestampMs / 1_000);
});
test('should not log a warning if native component exists and not in new architecture', async () => {
-
(isTurboModuleEnabled as jest.Mock).mockReturnValue(false);
TestRenderer.create();
@@ -390,7 +301,6 @@ describe('TimeToDisplay', () => {
});
test('should log a warning if in new architecture', async () => {
-
(isTurboModuleEnabled as jest.Mock).mockReturnValue(true);
TestRenderer.create();
await jest.runOnlyPendingTimersAsync(); // Flush setTimeout.
@@ -400,62 +310,65 @@ describe('TimeToDisplay', () => {
});
});
-function getInitialDisplaySpan(span?: Span) {
- return getSpanDescendants(span!)?.find(s => spanToJSON(s).op === 'ui.load.initial_display');
+function getInitialDisplaySpanJSON(spans: SpanJSON[]) {
+ return spans.find(s => s.op === 'ui.load.initial_display');
}
-function getFullDisplaySpan(span?: Span) {
- return getSpanDescendants(span!)?.find(s => spanToJSON(s).op === 'ui.load.full_display');
+function getFullDisplaySpanJSON(spans: SpanJSON[]) {
+ return spans.find(s => s.op === 'ui.load.full_display');
}
-function expectFinishedInitialDisplaySpan(actualSpan?: Span, expectedParentSpan?: Span) {
- expect(spanToJSON(actualSpan!)).toEqual(expect.objectContaining>({
+function expectFinishedInitialDisplaySpan(event: Event) {
+ expect(getInitialDisplaySpanJSON(event.spans!)).toEqual(expect.objectContaining>({
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: "ui.load.initial_display",
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY,
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
description: 'Time To Initial Display',
op: 'ui.load.initial_display',
- parent_span_id: expectedParentSpan ? spanToJSON(expectedParentSpan).span_id : undefined,
- start_timestamp: expect.any(Number),
+ parent_span_id: event.contexts.trace.span_id,
+ start_timestamp: event.start_timestamp,
status: 'ok',
timestamp: expect.any(Number),
}));
}
-function expectFinishedFullDisplaySpan(actualSpan?: Span, expectedParentSpan?: Span) {
- expect(spanToJSON(actualSpan!)).toEqual(expect.objectContaining>({
+function expectFinishedFullDisplaySpan(event: Event) {
+ expect(getFullDisplaySpanJSON(event.spans!)).toEqual(expect.objectContaining>({
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: "ui.load.full_display",
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY,
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
description: 'Time To Full Display',
op: 'ui.load.full_display',
- parent_span_id: expectedParentSpan ? spanToJSON(expectedParentSpan).span_id : undefined,
- start_timestamp: expect.any(Number),
+ parent_span_id: event.contexts.trace.span_id,
+ start_timestamp: event.start_timestamp,
status: 'ok',
timestamp: expect.any(Number),
}));
}
-function expectDeadlineExceededFullDisplaySpan(actualSpan?: Span, expectedParentSpan?: Span) {
- expect(spanToJSON(actualSpan!)).toEqual(expect.objectContaining>({
+function expectDeadlineExceededFullDisplaySpan(event: Event) {
+ expect(getFullDisplaySpanJSON(event.spans!)).toEqual(expect.objectContaining>({
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: "ui.load.full_display",
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY,
+ [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT,
},
description: 'Time To Full Display',
op: 'ui.load.full_display',
- parent_span_id: expectedParentSpan ? spanToJSON(expectedParentSpan).span_id : undefined,
- start_timestamp: expect.any(Number),
+ parent_span_id: event.contexts.trace.span_id,
+ start_timestamp: event.start_timestamp,
status: 'deadline_exceeded',
timestamp: expect.any(Number),
}));
}
-function expectNoTimeToDisplaySpans(span?: Span) {
- expect(getSpanDescendants(span!).map(spanToJSON)).toEqual(expect.not.arrayContaining([
+function expectNoTimeToDisplaySpans(event: Event) {
+ expect(event.spans).toEqual(expect.not.arrayContaining([
expect.objectContaining>({ op: 'ui.load.initial_display' }),
expect.objectContaining>({ op: 'ui.load.full_display' }),
]));
diff --git a/packages/core/test/tracing/timetodisplaynative.test.tsx b/packages/core/test/tracing/timetodisplaynative.test.tsx
new file mode 100644
index 0000000000..ec4dd5d75d
--- /dev/null
+++ b/packages/core/test/tracing/timetodisplaynative.test.tsx
@@ -0,0 +1,15 @@
+import { getRNSentryOnDrawReporter } from '../../src/js/tracing/timetodisplaynative';
+import * as Environment from '../../src/js/utils/environment';
+
+describe('timetodisplaynative', () => {
+ beforeEach(() => {
+ jest.spyOn(Environment, 'isExpoGo').mockReturnValue(false);
+ });
+
+ test('getRNSentryOnDrawReporter returns Noop in Expo Go', () => {
+ jest.spyOn(Environment, 'isExpoGo').mockReturnValue(true);
+ const drawReported = getRNSentryOnDrawReporter();
+
+ expect(drawReported.name).toBe('RNSentryOnDrawReporterNoop');
+ });
+});
diff --git a/packages/core/test/wrap.test.tsx b/packages/core/test/wrap.test.tsx
index 6272de9cf3..f949b36d38 100644
--- a/packages/core/test/wrap.test.tsx
+++ b/packages/core/test/wrap.test.tsx
@@ -1,6 +1,11 @@
+import { logger, setCurrentClient } from '@sentry/core';
+import { render } from '@testing-library/react-native';
import * as React from 'react';
+import { Text } from 'react-native';
+import * as AppRegistry from '../src/js/integrations/appRegistry';
import { wrap } from '../src/js/sdk';
+import { getDefaultTestClientOptions, TestClient } from './mocks/client';
describe('Sentry.wrap', () => {
it('should not enforce any keys on the wrapped component', () => {
@@ -9,4 +14,23 @@ describe('Sentry.wrap', () => {
expect(typeof ActualWrapped.defaultProps).toBe(typeof Mock.defaultProps);
});
+
+ it('should wrap the component and init with a warning when getAppRegistryIntegration returns undefined', () => {
+ logger.warn = jest.fn();
+ const getAppRegistryIntegration = jest.spyOn(AppRegistry, 'getAppRegistryIntegration').mockReturnValueOnce(undefined);
+ const Mock: React.FC = () => Test;
+ const client = new TestClient(
+ getDefaultTestClientOptions(),
+ );
+ setCurrentClient(client);
+
+ client.init();
+ const ActualWrapped = wrap(Mock);
+
+ const { getByText } = render();
+
+ expect(getAppRegistryIntegration).toHaveBeenCalled();
+ expect(logger.warn).toHaveBeenCalledWith('AppRegistryIntegration.onRunApplication not found or invalid.');
+ expect(getByText('Test')).toBeTruthy();
+ });
});
diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts
index 9a260f1a18..f0cc9be167 100644
--- a/packages/core/test/wrapper.test.ts
+++ b/packages/core/test/wrapper.test.ts
@@ -14,6 +14,7 @@ jest.mock('react-native', () => {
addBreadcrumb: jest.fn(),
captureEnvelope: jest.fn(),
clearBreadcrumbs: jest.fn(),
+ crashedLastRun: jest.fn(),
crash: jest.fn(),
fetchNativeDeviceContexts: jest.fn(() =>
Promise.resolve({
@@ -784,4 +785,27 @@ describe('Tests Native Wrapper', () => {
expect(RNSentry.setContext).toHaveBeenCalledOnce();
});
});
+
+ describe('crashedLastRun', () => {
+ test('return true when promise resolves true ', async () => {
+ (RNSentry.crashedLastRun as jest.Mock).mockResolvedValue(true);
+
+ const result = await NATIVE.crashedLastRun();
+ expect(result).toBeTrue();
+ });
+
+ test('return true when promise resolves false ', async () => {
+ (RNSentry.crashedLastRun as jest.Mock).mockResolvedValue(false);
+
+ const result = await NATIVE.crashedLastRun();
+ expect(result).toBeFalse();
+ });
+
+ test('return null when promise does not resolve ', async () => {
+ (RNSentry.crashedLastRun as jest.Mock).mockResolvedValue(undefined);
+
+ const result = await NATIVE.crashedLastRun();
+ expect(result).toBeNull();
+ });
+ });
});
diff --git a/performance-tests/TestAppPlain/package.json b/performance-tests/TestAppPlain/package.json
index ca67f73bec..219608b595 100644
--- a/performance-tests/TestAppPlain/package.json
+++ b/performance-tests/TestAppPlain/package.json
@@ -1,6 +1,6 @@
{
"name": "TestAppPlain",
- "version": "6.8.0",
+ "version": "6.15.1",
"private": true,
"scripts": {
"android": "react-native run-android",
diff --git a/performance-tests/TestAppSentry/package.json b/performance-tests/TestAppSentry/package.json
index c4a798aa5a..804b758063 100644
--- a/performance-tests/TestAppSentry/package.json
+++ b/performance-tests/TestAppSentry/package.json
@@ -1,6 +1,6 @@
{
"name": "TestAppSentry",
- "version": "6.8.0",
+ "version": "6.15.1",
"private": true,
"scripts": {
"android": "react-native run-android",
@@ -8,7 +8,7 @@
"start": "react-native start"
},
"dependencies": {
- "@sentry/react-native": "6.8.0",
+ "@sentry/react-native": "6.15.1",
"react": "18.1.0",
"react-native": "0.70.15"
},
diff --git a/performance-tests/metrics-ios.yml b/performance-tests/metrics-ios.yml
index d0235886d7..6210780f8f 100644
--- a/performance-tests/metrics-ios.yml
+++ b/performance-tests/metrics-ios.yml
@@ -11,4 +11,4 @@ startupTimeTest:
binarySizeTest:
diffMin: 600 KiB
- diffMax: 1200 KiB
+ diffMax: 1300 KiB
diff --git a/samples/expo/app.json b/samples/expo/app.json
index 1f1c89980d..5226bd9df5 100644
--- a/samples/expo/app.json
+++ b/samples/expo/app.json
@@ -3,8 +3,9 @@
"name": "sentry-react-native-expo-sample",
"slug": "sentry-react-native-expo-sample",
"jsEngine": "hermes",
+ "newArchEnabled": true,
"scheme": "sentry-expo-sample",
- "version": "6.8.0",
+ "version": "6.15.1",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
@@ -19,7 +20,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "io.sentry.expo.sample",
- "buildNumber": "38"
+ "buildNumber": "49"
},
"android": {
"adaptiveIcon": {
@@ -27,7 +28,7 @@
"backgroundColor": "#ffffff"
},
"package": "io.sentry.expo.sample",
- "versionCode": 38
+ "versionCode": 49
},
"web": {
"bundler": "metro",
@@ -65,10 +66,28 @@
{
"asyncRoutes": {
"web": true,
+ "ios": false,
"default": "development"
}
}
- ]
- ]
+ ],
+ "expo-web-browser"
+ ],
+ "extra": {
+ "router": {
+ "origin": false,
+ "asyncRoutes": {
+ "web": true,
+ "default": "development"
+ }
+ },
+ "eas": {}
+ },
+ "runtimeVersion": {
+ "policy": "appVersion"
+ },
+ "updates": {
+ "url": "https://u.expo.dev/00000000-0000-0000-0000-000000000000"
+ }
}
}
\ No newline at end of file
diff --git a/samples/expo/app/(tabs)/index.tsx b/samples/expo/app/(tabs)/index.tsx
index 947b22e471..fe6a630adb 100644
--- a/samples/expo/app/(tabs)/index.tsx
+++ b/samples/expo/app/(tabs)/index.tsx
@@ -2,19 +2,33 @@ import { Button, StyleSheet } from 'react-native';
import Constants from 'expo-constants';
import * as Sentry from '@sentry/react-native';
import { reloadAppAsync } from 'expo';
+import * as DevClient from 'expo-dev-client';
import { Text, View } from '@/components/Themed';
import { setScopeProperties } from '@/utils/setScopeProperties';
import React from 'react';
import * as WebBrowser from 'expo-web-browser';
+import { useUpdates } from 'expo-updates';
+import { isWeb } from '../../utils/isWeb';
-const isRunningInExpoGo = Constants.appOwnership === 'expo'
+const isRunningInExpoGo = Constants.appOwnership === 'expo';
export default function TabOneScreen() {
+ const { currentlyRunning } = useUpdates();
return (
Welcome to Sentry Expo Sample App!
+ Update ID: {currentlyRunning.updateId}
+ Channel: {currentlyRunning.channel}
+ Runtime Version: {currentlyRunning.runtimeVersion}
+