From 04f7bd97f666d704c37763848cac8ce82097dd3c Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 11 Mar 2025 13:01:09 +0100 Subject: [PATCH 1/7] chore(android): Bump Sentry Android SDK to v7.22.1 Updates the Android SDK dependency to the latest patch version, which includes potential bug fixes and improvements for measuring cold and warm starts. --- CHANGELOG.md | 8 +++++++- packages/core/android/build.gradle | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 726f6a71c3..c046871c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +- 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 ### Features @@ -16,7 +22,7 @@ ```js import Sentry from "@sentry/react-native"; - + Sentry.showFeedbackWidget(); Sentry.wrap(RootComponent); diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index c96506fb3c..a9e347c215 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.1' } From 8d68a8b7bed0e0240c1a38dd7f959a58110accab Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Mon, 10 Mar 2025 19:59:46 +0100 Subject: [PATCH 2/7] test(sample): Add E2E tests for app start transactions (#4619) --- .github/workflows/sample-application.yml | 162 +++++++++++++- .../android/app/src/main/AndroidManifest.xml | 3 +- .../main/res/xml/network_security_config.xml | 7 + samples/react-native/e2e/.gitignore | 1 + .../e2e/captureTransaction.test.ts | 209 ++++++++++++++++++ .../e2e/captureTransaction.test.yml | 13 ++ .../react-native/e2e/jest.config.android.js | 8 + samples/react-native/e2e/jest.config.base.js | 9 + samples/react-native/e2e/jest.config.ios.js | 8 + samples/react-native/e2e/setup.android.ts | 7 + samples/react-native/e2e/setup.ios.ts | 7 + samples/react-native/e2e/utils/consts.ts | 2 + samples/react-native/e2e/utils/environment.ts | 23 ++ samples/react-native/e2e/utils/event.ts | 11 + samples/react-native/e2e/utils/maestro.ts | 25 +++ .../e2e/utils/mockedSentryServer.ts | 148 +++++++++++++ .../react-native/e2e/utils/parseEnvelope.ts | 74 +++++++ samples/react-native/jest.config.js | 7 + samples/react-native/package.json | 7 + samples/react-native/scripts/test-android.sh | 27 +++ samples/react-native/scripts/test-ios.sh | 26 +++ samples/react-native/src/App.tsx | 15 +- samples/react-native/src/utils.ts | 23 ++ yarn.lock | 35 ++- 24 files changed, 847 insertions(+), 10 deletions(-) create mode 100644 samples/react-native/android/app/src/main/res/xml/network_security_config.xml create mode 100644 samples/react-native/e2e/.gitignore create mode 100644 samples/react-native/e2e/captureTransaction.test.ts create mode 100644 samples/react-native/e2e/captureTransaction.test.yml create mode 100644 samples/react-native/e2e/jest.config.android.js create mode 100644 samples/react-native/e2e/jest.config.base.js create mode 100644 samples/react-native/e2e/jest.config.ios.js create mode 100644 samples/react-native/e2e/setup.android.ts create mode 100644 samples/react-native/e2e/setup.ios.ts create mode 100644 samples/react-native/e2e/utils/consts.ts create mode 100644 samples/react-native/e2e/utils/environment.ts create mode 100644 samples/react-native/e2e/utils/event.ts create mode 100644 samples/react-native/e2e/utils/maestro.ts create mode 100644 samples/react-native/e2e/utils/mockedSentryServer.ts create mode 100644 samples/react-native/e2e/utils/parseEnvelope.ts create mode 100644 samples/react-native/jest.config.js create mode 100755 samples/react-native/scripts/test-android.sh create mode 100755 samples/react-native/scripts/test-ios.sh diff --git a/.github/workflows/sample-application.yml b/.github/workflows/sample-application.yml index 3ec82a6e31..b964854106 100644 --- a/.github/workflows/sample-application.yml +++ b/.github/workflows/sample-application.yml @@ -13,7 +13,14 @@ concurrency: env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + MAESTRO_VERSION: '1.39.0' 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 + REACT_NATIVE_SAMPLE_PATH: samples/react-native + IOS_DEVICE: 'iPhone 16' + IOS_VERSION: '18.1' + ANDROID_API_LEVEL: '30' jobs: diff_check: @@ -66,7 +73,7 @@ jobs: - uses: ruby/setup-ruby@v1 if: ${{ matrix.platform == 'ios' || matrix.platform == 'macos' }} with: - working-directory: ${{ matrix.platform == 'ios' && ' samples/react-native' || ' samples/react-native-macos' }} + working-directory: ${{ matrix.platform == 'ios' && env.REACT_NATIVE_SAMPLE_PATH || ' samples/react-native-macos' }} 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 @@ -106,7 +113,7 @@ jobs: - name: Build Android App if: ${{ matrix.platform == 'android' }} - working-directory: samples/react-native/android + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }}/android run: | if [[ ${{ matrix.rn-architecture }} == 'new' ]]; then perl -i -pe's/newArchEnabled=false/newArchEnabled=true/g' gradle.properties @@ -119,11 +126,14 @@ jobs: fi [[ "${{ matrix.build-type }}" == "production" ]] && CONFIG='Release' || CONFIG='Debug' echo "Building $CONFIG" + [[ "${{ matrix.build-type }}" == "production" ]] && TEST_TYPE='release' || TEST_TYPE='debug' + echo "Building $TEST_TYPE" + ./gradlew ":app:assemble$CONFIG" -PreactNativeArchitectures=x86 - name: Build iOS App if: ${{ matrix.platform == 'ios' }} - working-directory: samples/react-native/ios + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }}/ios run: | [[ "${{ matrix.build-type }}" == "production" ]] && CONFIG='Release' || CONFIG='Debug' echo "Building $CONFIG" @@ -160,9 +170,153 @@ jobs: | tee xcodebuild.log \ | xcbeautify --quieter --is-ci --disable-colored-output + - name: Archive iOS App + if: ${{ matrix.platform == 'ios' && matrix.rn-architecture == 'new' && matrix.build-type == 'production' && matrix.ios-use-frameworks == 'no-frameworks' }} + run: | + cd ${{ env.REACT_NATIVE_SAMPLE_PATH }}/ios/DerivedData/Build/Products/Release-iphonesimulator + zip -r \ + ${{ github.workspace }}/${{ env.IOS_APP_ARCHIVE_PATH }} \ + sentryreactnativesample.app + + - name: Archive Android App + if: ${{ matrix.platform == 'android' && matrix.rn-architecture == 'new' && matrix.build-type == 'production' }} + run: | + mv ${{ env.REACT_NATIVE_SAMPLE_PATH }}/android/app/build/outputs/apk/release/app-release.apk app.apk + zip -j \ + ${{ env.ANDROID_APP_ARCHIVE_PATH }} \ + app.apk + + - name: Upload iOS APP + if: ${{ matrix.platform == 'ios' && matrix.rn-architecture == 'new' && matrix.build-type == 'production' && matrix.ios-use-frameworks == 'no-frameworks' }} + uses: actions/upload-artifact@v4 + with: + name: sample-rn-${{ matrix.rn-architecture }}-${{ matrix.build-type }}-${{ matrix.ios-use-frameworks}}-${{ matrix.platform }} + path: ${{ env.IOS_APP_ARCHIVE_PATH }} + retention-days: 1 + + - name: Upload Android APK + if: ${{ matrix.platform == 'android' && matrix.rn-architecture == 'new' && matrix.build-type == 'production' }} + uses: actions/upload-artifact@v4 + with: + name: sample-rn-${{ matrix.rn-architecture }}-${{ matrix.build-type }}-${{ matrix.platform }} + path: ${{ env.ANDROID_APP_ARCHIVE_PATH }} + retention-days: 1 + - name: Upload logs if: ${{ always() }} uses: actions/upload-artifact@v4 with: name: build-sample-${{ matrix.rn-architecture }}-${{ matrix.platform }}-${{ matrix.build-type }}-${{ matrix.ios-use-frameworks}}-logs - path: samples/react-native/${{ matrix.platform }}/*.log + path: ${{ env.REACT_NATIVE_SAMPLE_PATH }}/${{ matrix.platform }}/*.log + + test: + name: Test ${{ matrix.platform }} ${{ matrix.build-type }} + runs-on: ${{ matrix.runs-on }} + needs: [diff_check, build] + if: ${{ needs.diff_check.outputs.skip_ci != 'true' }} + strategy: + # we want that the matrix keeps running, default is to cancel them if it fails. + fail-fast: false + matrix: + include: + - platform: ios + runs-on: macos-15 + rn-architecture: 'new' + ios-use-frameworks: 'no-frameworks' + build-type: 'production' + + - platform: android + runs-on: ubuntu-latest + rn-architecture: 'new' + build-type: 'production' + + 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 + with: + name: sample-rn-${{ matrix.rn-architecture }}-${{ matrix.build-type }}-${{ matrix.ios-use-frameworks}}-${{ matrix.platform }} + path: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + + - name: Download Android APK + if: ${{ matrix.platform == 'android' }} + uses: actions/download-artifact@v4 + with: + name: sample-rn-${{ matrix.rn-architecture }}-${{ matrix.build-type }}-${{ matrix.platform }} + path: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + + - name: Unzip iOS App Archive + if: ${{ matrix.platform == 'ios' }} + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + run: unzip ${{ env.IOS_APP_ARCHIVE_PATH }} + + - name: Unzip Android APK + if: ${{ matrix.platform == 'android' }} + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + run: unzip ${{ env.ANDROID_APP_ARCHIVE_PATH }} + + - name: Enable Corepack + run: | + npm install -g corepack@0.29.4 + corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: 'yarn' + cache-dependency-path: yarn.lock + + - name: Install JS Dependencies + run: yarn install + + - name: Setup KVM + if: ${{ matrix.platform == 'android' }} + shell: bash + run: | + # check if virtualization is supported... + sudo apt install -y --no-install-recommends cpu-checker coreutils && echo "CPUs=$(nproc --all)" && kvm-ok + # allow access to KVM to run the emulator + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Boot ${{ env.IOS_DEVICE }} with iOS ${{ env.IOS_VERSION }} + uses: futureware-tech/simulator-action@dab10d813144ef59b48d401cd95da151222ef8cd # pin@v4 + if: ${{ matrix.platform == 'ios' }} + with: + model: ${{ env.IOS_DEVICE }} + os_version: ${{ env.IOS_VERSION }} + + - name: Run iOS Tests + if: ${{ matrix.platform == 'ios' }} + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + run: yarn test-ios + + - name: Run Android Tests on API ${{ env.ANDROID_API_LEVEL }} + if: ${{ matrix.platform == 'android' }} + uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # pin@v2.33.0 + with: + api-level: ${{ env.ANDROID_API_LEVEL }} + force-avd-creation: false + disable-animations: true + disable-spellchecker: true + target: 'aosp_atd' + channel: canary # Necessary for ATDs + emulator-options: > + -no-window + -no-snapshot-save + -gpu swiftshader_indirect + -noaudio + -no-boot-anim + -camera-back none + -camera-front none + -timezone US/Pacific + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + script: yarn test-android diff --git a/samples/react-native/android/app/src/main/AndroidManifest.xml b/samples/react-native/android/app/src/main/AndroidManifest.xml index e1892528b8..095bdca459 100644 --- a/samples/react-native/android/app/src/main/AndroidManifest.xml +++ b/samples/react-native/android/app/src/main/AndroidManifest.xml @@ -9,7 +9,8 @@ android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" - android:supportsRtl="true"> + android:supportsRtl="true" + android:networkSecurityConfig="@xml/network_security_config"> + + + 10.0.2.2 + localhost + + diff --git a/samples/react-native/e2e/.gitignore b/samples/react-native/e2e/.gitignore new file mode 100644 index 0000000000..6722cd96e7 --- /dev/null +++ b/samples/react-native/e2e/.gitignore @@ -0,0 +1 @@ +*.xml diff --git a/samples/react-native/e2e/captureTransaction.test.ts b/samples/react-native/e2e/captureTransaction.test.ts new file mode 100644 index 0000000000..c4b9b2a645 --- /dev/null +++ b/samples/react-native/e2e/captureTransaction.test.ts @@ -0,0 +1,209 @@ +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; +import { EventItem } from '@sentry/core'; +import { + createSentryServer, + containingTransactionWithName, +} from './utils/mockedSentryServer'; + +import { getItemOfTypeFrom } from './utils/event'; +import { maestro } from './utils/maestro'; +import { isAndroid, isIOS } from './utils/environment'; + +describe('Capture transaction', () => { + let sentryServer = createSentryServer(); + sentryServer.start(); + + const getErrorsEnvelope = () => + sentryServer.getEnvelope(containingTransactionWithName('Errors')); + + const getTrackerEnvelope = () => + sentryServer.getEnvelope(containingTransactionWithName('Tracker')); + + beforeAll(async () => { + const waitForTrackerTx = sentryServer.waitForEnvelope( + containingTransactionWithName('Tracker'), // The last created and sent transaction + ); + const waitForErrorsTx = sentryServer.waitForEnvelope( + containingTransactionWithName('Errors'), // The last created and sent transaction + ); + + await maestro('captureTransaction.test.yml'); + + await Promise.all([waitForTrackerTx, waitForErrorsTx]); + }); + + afterAll(async () => { + await sentryServer.close(); + }); + + it('envelope contains transaction context', async () => { + const item = getItemOfTypeFrom( + getErrorsEnvelope(), + 'transaction', + ); + + expect(item).toEqual([ + expect.objectContaining({ + length: expect.any(Number), + type: 'transaction', + }), + expect.objectContaining({ + platform: 'javascript', + transaction: 'ErrorsScreen', + contexts: expect.objectContaining({ + trace: { + data: { + 'route.has_been_seen': false, + 'route.key': expect.stringMatching(/^ErrorsScreen/), + 'route.name': 'ErrorsScreen', + 'sentry.idle_span_finish_reason': 'idleTimeout', + 'sentry.op': 'ui.load', + 'sentry.origin': 'auto.app.start', + 'sentry.sample_rate': 1, + 'sentry.source': 'component', + 'thread.name': 'javascript', + }, + op: 'ui.load', + origin: 'auto.app.start', + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }), + }), + ]); + }); + + it('contains app start measurements', async () => { + const item = getItemOfTypeFrom( + getErrorsEnvelope(), + 'transaction', + ); + + if (isIOS()) { + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + time_to_initial_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + app_start_cold: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + ); + } else if (isAndroid()) { + // TMP: Until the cold app start is fixed on Android + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + time_to_initial_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + app_start_warm: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + ); + } + }); + + it('contains time to initial display measurements', async () => { + const item = getItemOfTypeFrom( + await getErrorsEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + time_to_initial_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + ); + }); + + it('contains JS stall measurements', async () => { + const item = getItemOfTypeFrom( + await getErrorsEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + stall_count: { + unit: 'none', + value: expect.any(Number), + }, + stall_longest_time: { + unit: 'millisecond', + value: expect.any(Number), + }, + stall_total_time: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + ); + }); + + it('contains time to display measurements', async () => { + const item = getItemOfTypeFrom( + getTrackerEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + time_to_initial_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + time_to_full_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + ); + }); + + it('contains at least one xhr breadcrumb of request to the tracker endpoint', async () => { + const item = getItemOfTypeFrom( + getTrackerEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + breadcrumbs: expect.arrayContaining([ + expect.objectContaining({ + category: 'xhr', + data: { + end_timestamp: expect.any(Number), + method: 'GET', + response_body_size: expect.any(Number), + start_timestamp: expect.any(Number), + status_code: expect.any(Number), + url: expect.stringContaining('api.covid19api.com/summary'), + }, + level: 'info', + timestamp: expect.any(Number), + type: 'http', + }), + ]), + }), + ); + }); +}); diff --git a/samples/react-native/e2e/captureTransaction.test.yml b/samples/react-native/e2e/captureTransaction.test.yml new file mode 100644 index 0000000000..9f59b1155a --- /dev/null +++ b/samples/react-native/e2e/captureTransaction.test.yml @@ -0,0 +1,13 @@ +appId: io.sentry.reactnative.sample +--- +- launchApp: + # We expect cold start + clearState: true + stopApp: true + arguments: + isE2ETest: true + +# For unknown reasons tapOn: "Performance" does not work on iOS +- tapOn: + id: "performance-tab-icon" +- tapOn: "Auto Tracing Example" diff --git a/samples/react-native/e2e/jest.config.android.js b/samples/react-native/e2e/jest.config.android.js new file mode 100644 index 0000000000..d84363325d --- /dev/null +++ b/samples/react-native/e2e/jest.config.android.js @@ -0,0 +1,8 @@ +const path = require('path'); +const baseConfig = require('./jest.config.base'); + +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + ...baseConfig, + globalSetup: path.resolve(__dirname, 'setup.android.ts'), +}; diff --git a/samples/react-native/e2e/jest.config.base.js b/samples/react-native/e2e/jest.config.base.js new file mode 100644 index 0000000000..49b0b27c51 --- /dev/null +++ b/samples/react-native/e2e/jest.config.base.js @@ -0,0 +1,9 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + preset: 'ts-jest', + rootDir: '..', + testMatch: ['/e2e/**/*.test.ts'], + testTimeout: 120000, + maxWorkers: 1, + verbose: true, +}; diff --git a/samples/react-native/e2e/jest.config.ios.js b/samples/react-native/e2e/jest.config.ios.js new file mode 100644 index 0000000000..482cc4e987 --- /dev/null +++ b/samples/react-native/e2e/jest.config.ios.js @@ -0,0 +1,8 @@ +const path = require('path'); +const baseConfig = require('./jest.config.base'); + +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + ...baseConfig, + globalSetup: path.resolve(__dirname, 'setup.ios.ts'), +}; diff --git a/samples/react-native/e2e/setup.android.ts b/samples/react-native/e2e/setup.android.ts new file mode 100644 index 0000000000..91c0dfec95 --- /dev/null +++ b/samples/react-native/e2e/setup.android.ts @@ -0,0 +1,7 @@ +import { setAndroid } from './utils/environment'; + +function setupAndroid() { + setAndroid(); +} + +export default setupAndroid; diff --git a/samples/react-native/e2e/setup.ios.ts b/samples/react-native/e2e/setup.ios.ts new file mode 100644 index 0000000000..b3f6a69385 --- /dev/null +++ b/samples/react-native/e2e/setup.ios.ts @@ -0,0 +1,7 @@ +import { setIOS } from './utils/environment'; + +function setupIOS() { + setIOS(); +} + +export default setupIOS; diff --git a/samples/react-native/e2e/utils/consts.ts b/samples/react-native/e2e/utils/consts.ts new file mode 100644 index 0000000000..9a751a5fa4 --- /dev/null +++ b/samples/react-native/e2e/utils/consts.ts @@ -0,0 +1,2 @@ +export const HEADER = 0; +export const ITEMS = 1; diff --git a/samples/react-native/e2e/utils/environment.ts b/samples/react-native/e2e/utils/environment.ts new file mode 100644 index 0000000000..cde97ea350 --- /dev/null +++ b/samples/react-native/e2e/utils/environment.ts @@ -0,0 +1,23 @@ +type TestGlobal = typeof globalThis & { + E2E_TEST_PLATFORM: 'android' | 'ios'; +}; + +function getTestGlobal(): TestGlobal { + return globalThis as TestGlobal; +} + +export function setAndroid(): void { + getTestGlobal().E2E_TEST_PLATFORM = 'android'; +} + +export function setIOS(): void { + getTestGlobal().E2E_TEST_PLATFORM = 'ios'; +} + +export function isAndroid(): boolean { + return getTestGlobal().E2E_TEST_PLATFORM === 'android'; +} + +export function isIOS(): boolean { + return getTestGlobal().E2E_TEST_PLATFORM === 'ios'; +} diff --git a/samples/react-native/e2e/utils/event.ts b/samples/react-native/e2e/utils/event.ts new file mode 100644 index 0000000000..df631feb4e --- /dev/null +++ b/samples/react-native/e2e/utils/event.ts @@ -0,0 +1,11 @@ +import { Envelope, EnvelopeItem } from '@sentry/core'; +import { HEADER, ITEMS } from './consts'; + +export function getItemOfTypeFrom( + envelope: Envelope, + type: string, +): T | undefined { + return (envelope[ITEMS] as [{ type?: string }, unknown][]).find( + i => i[HEADER].type === type, + ) as T | undefined; +} diff --git a/samples/react-native/e2e/utils/maestro.ts b/samples/react-native/e2e/utils/maestro.ts new file mode 100644 index 0000000000..af54e9bdb1 --- /dev/null +++ b/samples/react-native/e2e/utils/maestro.ts @@ -0,0 +1,25 @@ +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +/** + * Run a Maestro test and return a promise that resolves when the test is finished. + * + * @param test - The path to the Maestro test file relative to the `e2e` directory. + * @returns A promise that resolves when the test is finished. + */ +export const maestro = async (test: string) => { + return new Promise((resolve, reject) => { + const process = spawn('maestro', ['test', test, '--format', 'junit'], { + cwd: path.join(__dirname, '..'), + stdio: 'inherit', + }); + + process.on('close', code => { + if (code !== 0) { + reject(`Maestro test failed with code ${code}. See logs above.`); + } else { + resolve(undefined); + } + }); + }); +}; diff --git a/samples/react-native/e2e/utils/mockedSentryServer.ts b/samples/react-native/e2e/utils/mockedSentryServer.ts new file mode 100644 index 0000000000..43bbf80991 --- /dev/null +++ b/samples/react-native/e2e/utils/mockedSentryServer.ts @@ -0,0 +1,148 @@ +import { IncomingMessage, ServerResponse, createServer } from 'node:http'; +import { createGunzip } from 'node:zlib'; +import { Envelope, EnvelopeItem } from '@sentry/core'; +import { parseEnvelope } from './parseEnvelope'; +import { Event } from '@sentry/core'; + +type RecordedRequest = { + path: string | undefined; + headers: Record; + body: Buffer; + envelope: Envelope; +}; + +export function createSentryServer({ port = 8961 } = {}): { + waitForEnvelope: ( + predicate: (envelope: Envelope) => boolean, + ) => Promise; + close: () => Promise; + start: () => void; + getEnvelope: (predicate: (envelope: Envelope) => boolean) => Envelope; +} { + const nextRequestCallbacks: (typeof onNextRequestCallback)[] = []; + let onNextRequestCallback: (request: RecordedRequest) => void = ( + request: RecordedRequest, + ) => { + nextRequestCallbacks.forEach(callback => callback(request)); + }; + const requests: RecordedRequest[] = []; + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + let body: Buffer = Buffer.from([]); + + const gunzip = createGunzip(); + req.pipe(gunzip); + + gunzip.on('data', (chunk: Buffer) => { + body = Buffer.concat([body, chunk]); + }); + + gunzip.on('end', () => { + const request = { + path: req.url, + headers: req.headers, + body: body, + envelope: parseEnvelope(body), + }; + requests.push(request); + + body = Buffer.from([]); + + res.writeHead(200); + res.end('OK'); + + onNextRequestCallback(request); + }); + }); + + return { + start: () => { + server.listen(port); + }, + waitForEnvelope: async ( + predicate: (envelope: Envelope) => boolean, + ): Promise => { + return new Promise((resolve, reject) => { + nextRequestCallbacks.push((request: RecordedRequest) => { + try { + if (predicate(request.envelope)) { + resolve(request.envelope); + return; + } + } catch (e) { + reject(e); + return; + } + }); + }); + }, + close: async () => { + await new Promise(resolve => { + server.close(() => resolve()); + }); + }, + getEnvelope: (predicate: (envelope: Envelope) => boolean) => { + const envelope = requests.find( + request => request.envelope && predicate(request.envelope), + )?.envelope; + + if (!envelope) { + throw new Error('Envelope not found'); + } + + return envelope; + }, + }; +} + +export function containingEvent(envelope: Envelope) { + return envelope[1].some(item => itemHeaderIsType(item[0], 'event')); +} + +export function containingEventWithAndroidMessage(message: string) { + return (envelope: Envelope) => + envelope[1].some( + item => + itemHeaderIsType(item[0], 'event') && + itemBodyIsEvent(item[1]) && + item[1].message && + (item[1].message as unknown as { message: string }).message === message, + ); +} + +export function containingEventWithMessage(message: string) { + return (envelope: Envelope) => + envelope[1].some( + item => + itemHeaderIsType(item[0], 'event') && + itemBodyIsEvent(item[1]) && + item[1].message === message, + ); +} + +export function containingTransactionWithName(name: string) { + return (envelope: Envelope) => + envelope[1].some( + item => + itemHeaderIsType(item[0], 'transaction') && + itemBodyIsEvent(item[1]) && + item[1].transaction && + item[1].transaction.includes(name), + ); +} + +export function itemBodyIsEvent(itemBody: EnvelopeItem[1]): itemBody is Event { + return typeof itemBody === 'object' && 'event_id' in itemBody; +} + +export function itemHeaderIsType(itemHeader: EnvelopeItem[0], type: string) { + if (typeof itemHeader !== 'object' || !('type' in itemHeader)) { + return false; + } + + if (itemHeader.type !== type) { + return false; + } + + return true; +} diff --git a/samples/react-native/e2e/utils/parseEnvelope.ts b/samples/react-native/e2e/utils/parseEnvelope.ts new file mode 100644 index 0000000000..e6b29b201e --- /dev/null +++ b/samples/react-native/e2e/utils/parseEnvelope.ts @@ -0,0 +1,74 @@ +import { + Envelope, + BaseEnvelopeHeaders, + BaseEnvelopeItemHeaders, +} from '@sentry/core'; + +/** + * Parses an envelope + */ +export function parseEnvelope(env: string | Uint8Array): Envelope { + let buffer = typeof env === 'string' ? encodeUTF8(env) : env; + + function readBinary(length?: number): Uint8Array { + if (!length) { + throw new Error('Binary Envelope Items must have a length to be read'); + } + const bin = buffer.subarray(0, length); + // Replace the buffer with the remaining data excluding trailing newline + buffer = buffer.subarray(length + 1); + return bin; + } + + function readJson(): T { + let i = buffer.indexOf(0xa); + // If we couldn't find a newline, we must have found the end of the buffer + if (i < 0) { + i = buffer.length; + } + + return JSON.parse(decodeUTF8(readBinary(i))) as T; + } + + const envelopeHeader = readJson(); + + const items: [any, any][] = []; + + while (buffer.length) { + const itemHeader = readJson(); + const isBinaryAttachment = + itemHeader.type === 'attachment' && + itemHeader.content_type !== 'application/json'; + // TODO: Parse when needed for the tests + const isReplayVideo = (itemHeader.type as string) === 'replay_video'; + + try { + let item: any = {}; + if (isReplayVideo || isBinaryAttachment) { + item = readBinary(itemHeader.length); + } else { + item = readJson(); + } + items.push([itemHeader, item]); + } catch (e) { + console.error(e, 'itemHeader', itemHeader, 'buffer', buffer.toString()); + throw e; + } + } + + return [envelopeHeader, items]; +} + +/** + * Encode a string to UTF8 array. + */ +function encodeUTF8(input: string): Uint8Array { + return new TextEncoder().encode(input); +} + +/** + * Decode a UTF8 array to string. + */ +function decodeUTF8(input: Uint8Array): string { + return new TextDecoder().decode(input); +} diff --git a/samples/react-native/jest.config.js b/samples/react-native/jest.config.js new file mode 100644 index 0000000000..27803eeafc --- /dev/null +++ b/samples/react-native/jest.config.js @@ -0,0 +1,7 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + testMatch: [ + '/__tests__/**/*-test.ts', + '/__tests__/**/*-test.tsx', + ], +}; diff --git a/samples/react-native/package.json b/samples/react-native/package.json index 062b817bc4..78a07acd80 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -8,6 +8,8 @@ "ios": "react-native run-ios", "start": "react-native start", "test": "jest", + "test-android": "scripts/test-android.sh", + "test-ios": "scripts/test-ios.sh", "lint": "npx eslint . --ext .js,.jsx,.ts,.tsx", "fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", "pod-install": "cd ios; RCT_NEW_ARCH_ENABLED=1 bundle exec pod install; cd ..", @@ -25,6 +27,7 @@ "@react-navigation/native": "^7.0.14", "@react-navigation/native-stack": "^7.2.0", "@react-navigation/stack": "^7.1.1", + "@sentry/core": "8.54.0", "@sentry/react-native": "6.9.0", "delay": "^6.0.0", "react": "18.3.1", @@ -32,6 +35,7 @@ "react-native-gesture-handler": "^2.22.1", "react-native-image-picker": "^8.0.0", "react-native-reanimated": "3.16.7", + "react-native-launch-arguments": "^4.1.0", "react-native-safe-area-context": "5.2.0", "react-native-screens": "4.6.0", "react-native-svg": "^15.11.1", @@ -52,6 +56,8 @@ "@react-native/metro-config": "0.77.1", "@react-native/typescript-config": "0.77.1", "@sentry/babel-plugin-component-annotate": "3.2.0", + "@types/jest": "^29.5.14", + "@types/node": "^22.13.1", "@types/react": "^18.2.65", "@types/react-native-vector-icons": "^6.4.18", "@types/react-test-renderer": "^18.0.0", @@ -66,6 +72,7 @@ "prettier": "2.8.8", "react-test-renderer": "18.3.1", "sentry-react-native-samples-utils": "workspace:^", + "ts-jest": "^29.2.5", "typescript": "5.0.4" }, "engines": { diff --git a/samples/react-native/scripts/test-android.sh b/samples/react-native/scripts/test-android.sh new file mode 100755 index 0000000000..de4b5d5e87 --- /dev/null +++ b/samples/react-native/scripts/test-android.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -e -x # exit on error, print commands + +# Get current directory +thisFileDirPath=$(dirname "$0") +reactProjectRootPath="$(cd "$thisFileDirPath/.." && pwd)" + +maybeApkPath=$(find "${reactProjectRootPath}" -maxdepth 1 -name "*.apk") + +# Check if any APK files exist +apk_count=$(echo "$maybeApkPath" | wc -l) + +if [ -n "$maybeApkPath" ] && [ $apk_count -eq 1 ]; then + # Force install single APK using adb + apk_file="${maybeApkPath}" + echo "Installing $apk_file..." + adb install -r "$apk_file" +elif [ $apk_count -gt 1 ]; then + echo "Error: Multiple APK files found. Expected only one APK file." + exit 1 +else + echo "No APK files found, continuing without install" +fi + +# Run the tests +npx jest --config e2e/jest.config.android.js diff --git a/samples/react-native/scripts/test-ios.sh b/samples/react-native/scripts/test-ios.sh new file mode 100755 index 0000000000..e242dde917 --- /dev/null +++ b/samples/react-native/scripts/test-ios.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +set -e -x # exit on error, print commands + +# Get current directory +thisFileDirPath=$(dirname "$0") +reactProjectRootPath="$(cd "$thisFileDirPath/.." && pwd)" + +maybeAppPath=$(find "${reactProjectRootPath}" -maxdepth 1 -name "*.app") + +# Check if any APP files exist +app_count=$(echo "$maybeAppPath" | wc -l) + +if [ -n "$maybeAppPath" ] && [ $app_count -eq 1 ]; then + app_file="${maybeAppPath}" + echo "Installing $app_file..." + xcrun simctl install booted "$app_file" +elif [ $app_count -gt 1 ]; then + echo "Error: Multiple APP files found. Expected only one APP file." + exit 1 +else + echo "No APP files found, continuing without install" +fi + +# Run the tests +npx jest --config e2e/jest.config.ios.js diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index f0e612d862..831f131dad 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -18,7 +18,6 @@ import Animated, { import * as Sentry from '@sentry/react-native'; import { FeedbackWidget } from '@sentry/react-native'; -import { SENTRY_INTERNAL_DSN } from './dsn'; import ErrorsScreen from './Screens/ErrorsScreen'; import PerformanceScreen from './Screens/PerformanceScreen'; import TrackerScreen from './Screens/TrackerScreen'; @@ -32,13 +31,16 @@ import GesturesTracingScreen from './Screens/GesturesTracingScreen'; import { LogBox, Platform, StyleSheet, View } from 'react-native'; import Ionicons from 'react-native-vector-icons/Ionicons'; import PlaygroundScreen from './Screens/PlaygroundScreen'; -import { logWithoutTracing } from './utils'; +import { getDsn, logWithoutTracing } from './utils'; import { ErrorEvent } from '@sentry/core'; import HeavyNavigationScreen from './Screens/HeavyNavigationScreen'; import WebviewScreen from './Screens/WebviewScreen'; import { isTurboModuleEnabled } from '@sentry/react-native/dist/js/utils/environment'; import * as ImagePicker from 'react-native-image-picker'; +/* false by default to avoid issues in e2e tests waiting for the animation end */ +const RUNNING_INDICATOR = false; + if (typeof setImmediate === 'undefined') { require('setimmediate'); } @@ -54,7 +56,7 @@ const reactNavigationIntegration = Sentry.reactNavigationIntegration({ Sentry.init({ // Replace the example DSN below with your own DSN: - dsn: SENTRY_INTERNAL_DSN, + dsn: getDsn(), debug: true, environment: 'dev', beforeSend: (event: ErrorEvent) => { @@ -248,6 +250,7 @@ function BottomTabsNavigator() { name={focused ? 'bug' : 'bug-outline'} size={size} color={color} + testID="errors-tab-icon" /> ), }} @@ -263,6 +266,7 @@ function BottomTabsNavigator() { name={focused ? 'speedometer' : 'speedometer-outline'} size={size} color={color} + testID="performance-tab-icon" /> ), }} @@ -280,6 +284,7 @@ function BottomTabsNavigator() { } size={size} color={color} + testID="playground-tab-icon" /> ), }} @@ -322,6 +327,10 @@ function RunningIndicator() { return null; } + if (!RUNNING_INDICATOR) { + return null; + } + return ; } diff --git a/samples/react-native/src/utils.ts b/samples/react-native/src/utils.ts index 8681333e30..1404c2ce36 100644 --- a/samples/react-native/src/utils.ts +++ b/samples/react-native/src/utils.ts @@ -1,3 +1,8 @@ +import { Platform } from 'react-native'; +import { LaunchArguments } from 'react-native-launch-arguments'; + +import { SENTRY_INTERNAL_DSN } from './dsn'; + export function logWithoutTracing(...args: unknown[]) { if ('__sentry_original__' in console.log) { console.log.__sentry_original__(...args); @@ -5,3 +10,21 @@ export function logWithoutTracing(...args: unknown[]) { console.log(...args); } } + +export const isE2ETest = () => { + try { + return !!LaunchArguments.value().isE2ETest; + } catch (e) { + return false; + } +}; + +export const getDsn = () => { + if (isE2ETest() && Platform.OS === 'android') { + return 'http://key@10.0.2.2:8961/123456'; + } + if (isE2ETest() && Platform.OS === 'ios') { + return 'http://key@localhost:8961/123456'; + } + return SENTRY_INTERNAL_DSN; +}; diff --git a/yarn.lock b/yarn.lock index 14d8b4250a..bf9e671d10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8711,7 +8711,7 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^29.5.13": +"@types/jest@npm:^29.5.13, @types/jest@npm:^29.5.14": version: 29.5.14 resolution: "@types/jest@npm:29.5.14" dependencies: @@ -8860,6 +8860,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.13.1": + version: 22.13.1 + resolution: "@types/node@npm:22.13.1" + dependencies: + undici-types: ~6.20.0 + checksum: a0759e4bedc3fe892c3ddef5fa9cb5251f9c5b24defc1a389438ea3b5b727c481c1a9bc94bae4ecc7426c89ad293cd66633d163da1ab14d74d358cbec9e1ce31 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -22959,6 +22968,16 @@ __metadata: languageName: node linkType: hard +"react-native-launch-arguments@npm:^4.1.0": + version: 4.1.0 + resolution: "react-native-launch-arguments@npm:4.1.0" + peerDependencies: + react: ">=16.8.1" + react-native: ">=0.60.0-rc.0 <1.0.x" + checksum: 719b6cbfb0bb77152b94f0f96c30a90847b9ade4908936435d2c2024e81d2538968a9225457d443f84513e72487565c7b31472307db37292640ffb2a77315e3e + languageName: node + linkType: hard + "react-native-macos@npm:0.73.34": version: 0.73.34 resolution: "react-native-macos@npm:0.73.34" @@ -24687,7 +24706,10 @@ __metadata: "@react-navigation/native-stack": ^7.2.0 "@react-navigation/stack": ^7.1.1 "@sentry/babel-plugin-component-annotate": 3.2.0 + "@sentry/core": 8.54.0 "@sentry/react-native": 6.9.0 + "@types/jest": ^29.5.14 + "@types/node": ^22.13.1 "@types/react": ^18.2.65 "@types/react-native-vector-icons": ^6.4.18 "@types/react-test-renderer": ^18.0.0 @@ -24705,6 +24727,7 @@ __metadata: react-native: 0.77.1 react-native-gesture-handler: ^2.22.1 react-native-image-picker: ^8.0.0 + react-native-launch-arguments: ^4.1.0 react-native-reanimated: 3.16.7 react-native-safe-area-context: 5.2.0 react-native-screens: 4.6.0 @@ -24715,6 +24738,7 @@ __metadata: react-test-renderer: 18.3.1 redux: ^4.2.1 sentry-react-native-samples-utils: "workspace:^" + ts-jest: ^29.2.5 typescript: 5.0.4 languageName: unknown linkType: soft @@ -26292,7 +26316,7 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^29.1.1": +"ts-jest@npm:^29.1.1, ts-jest@npm:^29.2.5": version: 29.2.5 resolution: "ts-jest@npm:29.2.5" dependencies: @@ -26812,6 +26836,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~6.20.0": + version: 6.20.0 + resolution: "undici-types@npm:6.20.0" + checksum: b7bc50f012dc6afbcce56c9fd62d7e86b20a62ff21f12b7b5cbf1973b9578d90f22a9c7fe50e638e96905d33893bf2f9f16d98929c4673c2480de05c6c96ea8b + languageName: node + linkType: hard + "undici@npm:^6.11.1, undici@npm:^6.18.2": version: 6.21.1 resolution: "undici@npm:6.21.1" From d7d6ef37803c324fa4f0a6cc7cb03602b5141b58 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:10:02 +0100 Subject: [PATCH 3/7] Update CHANGELOG.md --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c046871c64..200574ec81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,13 @@ ## Unreleased -- Bump Android SDK from v7.22.0 to v7.22.1([#4643](https://github.com/getsentry/sentry-react-native/pull/4643)) +### 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)) + +### 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) From 7fce42e525824f2403be536613ac86ba0ec919ed Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Tue, 4 Mar 2025 18:57:37 +0100 Subject: [PATCH 4/7] fix(appStart): Attach App Start spans to the first created not the first processed root span (#4618) --- CHANGELOG.md | 1 + .../src/js/tracing/integrations/appStart.ts | 48 ++++++++++++++++--- .../core/test/profiling/integration.test.ts | 3 +- .../tracing/integrations/appStart.test.ts | 10 +++- .../tracing/reactnavigation.ttid.test.tsx | 1 + 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 200574ec81..aee1d42906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### 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 diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 0f96557d4c..8a35e1aa01 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, @@ -17,7 +17,7 @@ import { } 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, @@ -136,16 +136,18 @@ export const appStartIntegration = ({ let _client: Client | undefined = undefined; let isEnabled = true; let appStartDataFlushed = 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 => { @@ -167,6 +169,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( @@ -212,11 +235,23 @@ export const appStartIntegration = ({ return; } + if (!firstStartedActiveRootSpanId) { + logger.warn('[AppStart] No first started active root span id recorded. Can not attach app start.'); + return; + } + if (!event.contexts || !event.contexts.trace) { logger.warn('[AppStart] Transaction event is missing trace context. Can not attach app start.'); 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.'); @@ -332,7 +367,8 @@ export const appStartIntegration = ({ afterAllSetup, processEvent, captureStandaloneAppStart, - }; + setFirstStartedActiveRootSpanId, + } as AppStartIntegration; }; function setSpanDurationAsMeasurementOnTransactionEvent(event: TransactionEvent, label: string, span: SpanJSON): void { diff --git a/packages/core/test/profiling/integration.test.ts b/packages/core/test/profiling/integration.test.ts index 83da5cc53d..7462d94c6e 100644 --- a/packages/core/test/profiling/integration.test.ts +++ b/packages/core/test/profiling/integration.test.ts @@ -262,8 +262,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/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index 4337e3e2b3..bd01beb503 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -33,6 +33,10 @@ import { NATIVE } from '../../../src/js/wrapper'; 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', () => { @@ -689,7 +693,10 @@ describe('App Start Integration', () => { 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 }), ); @@ -722,6 +729,7 @@ describe('App Start Integration', () => { function processEvent(event: Event): PromiseLike | Event | null { const integration = appStartIntegration(); + (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(event.contexts?.trace?.span_id); return integration.processEvent(event, {}, new TestClient(getDefaultTestClientOptions())); } diff --git a/packages/core/test/tracing/reactnavigation.ttid.test.tsx b/packages/core/test/tracing/reactnavigation.ttid.test.tsx index a0245cff12..50efa84fca 100644 --- a/packages/core/test/tracing/reactnavigation.ttid.test.tsx +++ b/packages/core/test/tracing/reactnavigation.ttid.test.tsx @@ -359,6 +359,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 From c750acbfdb4db53d00d0cb72096e343d7c6aa6c7 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 11 Mar 2025 13:23:25 +0100 Subject: [PATCH 5/7] remove thread check, this version of the SDK doesn't report threads --- samples/react-native/e2e/captureTransaction.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/samples/react-native/e2e/captureTransaction.test.ts b/samples/react-native/e2e/captureTransaction.test.ts index c4b9b2a645..2e30b2c3c3 100644 --- a/samples/react-native/e2e/captureTransaction.test.ts +++ b/samples/react-native/e2e/captureTransaction.test.ts @@ -61,7 +61,6 @@ describe('Capture transaction', () => { 'sentry.origin': 'auto.app.start', 'sentry.sample_rate': 1, 'sentry.source': 'component', - 'thread.name': 'javascript', }, op: 'ui.load', origin: 'auto.app.start', From f302c69b197f765eba0caffe8186f69163871c39 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich Date: Tue, 11 Mar 2025 13:23:46 +0100 Subject: [PATCH 6/7] remove android workaround, cold start is now expected to be reported --- .../e2e/captureTransaction.test.ts | 44 ++++++------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/samples/react-native/e2e/captureTransaction.test.ts b/samples/react-native/e2e/captureTransaction.test.ts index 2e30b2c3c3..ca5beae1cf 100644 --- a/samples/react-native/e2e/captureTransaction.test.ts +++ b/samples/react-native/e2e/captureTransaction.test.ts @@ -78,38 +78,20 @@ describe('Capture transaction', () => { 'transaction', ); - if (isIOS()) { - expect(item?.[1]).toEqual( - expect.objectContaining({ - measurements: expect.objectContaining({ - time_to_initial_display: { - unit: 'millisecond', - value: expect.any(Number), - }, - app_start_cold: { - unit: 'millisecond', - value: expect.any(Number), - }, - }), - }), - ); - } else if (isAndroid()) { - // TMP: Until the cold app start is fixed on Android - expect(item?.[1]).toEqual( - expect.objectContaining({ - measurements: expect.objectContaining({ - time_to_initial_display: { - unit: 'millisecond', - value: expect.any(Number), - }, - app_start_warm: { - unit: 'millisecond', - value: expect.any(Number), - }, - }), + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + time_to_initial_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + app_start_cold: { + unit: 'millisecond', + value: expect.any(Number), + }, }), - ); - } + }), + ); }); it('contains time to initial display measurements', async () => { From 8d138305d802a9b9bea975953d85e53866ad61be Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:52:10 +0100 Subject: [PATCH 7/7] Update captureTransaction.test.ts --- samples/react-native/e2e/captureTransaction.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/samples/react-native/e2e/captureTransaction.test.ts b/samples/react-native/e2e/captureTransaction.test.ts index ca5beae1cf..89323d5d5e 100644 --- a/samples/react-native/e2e/captureTransaction.test.ts +++ b/samples/react-native/e2e/captureTransaction.test.ts @@ -7,7 +7,6 @@ import { import { getItemOfTypeFrom } from './utils/event'; import { maestro } from './utils/maestro'; -import { isAndroid, isIOS } from './utils/environment'; describe('Capture transaction', () => { let sentryServer = createSentryServer();