Skip to content

feat(tracing): Add standalone app start transaction#6359

Draft
antonis wants to merge 7 commits into
mainfrom
antonislilis/rn-541-feattracing-decouple-app-start-data-from-navigation
Draft

feat(tracing): Add standalone app start transaction#6359
antonis wants to merge 7 commits into
mainfrom
antonislilis/rn-541-feattracing-decouple-app-start-data-from-navigation

Conversation

@antonis

@antonis antonis commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

📢 Type of change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring

📜 Description

Adds an experimental, opt-in flag _experiments.enableStandaloneAppStartTracing that sends app start as a dedicated app.start transaction instead of attaching app start data to the first navigation (ui.load) transaction.

💡 Motivation and Context

Closes #5839.

💚 How did you test it?

  • Unit tests in appStart.test.ts migrated to the V2 encoding (op app.start, vitals attributes, no legacy span/measurement); breakdown-span, frame-data, and appLoaded() standalone tests updated accordingly.
  • New trace-connection test: the app.start transaction shares trace_id with the navigation transaction.
  • New defaultAppStart.test.ts: verifies the flag wires standalone on/off/default.
  • Added hermetic snapshot tests that byte-lock the full event for both flows. The default (non-opt-in) snapshot was generated against the pre-change SDK and matches unchanged after the change — proving the default path is byte-identical; the standalone snapshot guards the Span V2 encoding.
  • Added a regression test for the standalone age-check fix (slow device: app start timestamp older than MAX_APP_START_AGE_MS is still sent), with the non-standalone path still asserting the age check applies.
  • Added regression tests for the duplicate-transaction fixes: (a) deferred auto-capture fires, then appLoaded() arrives, and (b) appLoaded() races an in-flight deferred capture → in both cases only one app.start transaction is sent; verified each fails before the fix (2 transactions) and passes after.
  • Full suite green. Build, API report, lint, and circular-dep checks all pass.

📝 Checklist

  • I added tests to verify changes
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • All tests passing
  • No breaking changes

🔮 Next steps

@linear-code

linear-code Bot commented Jun 26, 2026

Copy link
Copy Markdown

RN-541

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Semver Impact of This PR

None (no version bump detected)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


  • feat(tracing): Add standalone app start transaction by antonis in #6359
  • docs: Add missing 8.14.1 to changelog and SDK versions table by antonis in #6360
  • chore(deps): update Android SDK to v8.46.0 by github-actions in #6357
  • chore(ci): Move testflight and size-analysis iOS jobs to GitHub Actions macos-26 by itaybre in #6355
  • feat(core): Use native btoa for envelope base64 encoding by alwx in #6351

🤖 This preview updates automatically when you update the PR.

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor
Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against 3ddd44c

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit d5b4af2. Configure here.

Comment thread packages/core/src/js/tracing/integrations/appStart.ts
Comment thread packages/core/src/js/integrations/default.ts
antonis added a commit that referenced this pull request Jun 26, 2026
The MAX_APP_START_AGE_MS bounds check compared the native app start time against
`event.start_timestamp`, which for a standalone transaction still holds the span
creation time at that point (it is corrected to the native app start time later).
On slow devices that gap can exceed the threshold and discard a valid app start.
The age check only makes sense for the non-standalone path (where app start is
attached to a later navigation transaction), so skip it for standalone; the
duration check still filters genuinely bogus app starts.

Surfaced by automated review on #6359.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
antonis added a commit that referenced this pull request Jun 26, 2026
In standalone mode the deferred auto-capture (`setTimeout(0)`) is meant to be
cancelled by a later `appLoaded()` call. But once the deferred send has fired,
`cancelDeferredStandaloneCapture()` is a no-op and `_appLoaded()` reset the
flushed flag and sent a second `app.start` transaction for the same app run
(the common case, since apps signal readiness in a later macrotask).

Track whether a standalone transaction has already been sent and skip
re-sending; reset the flag on `runApplication` so a new app run can send again.

Surfaced by automated review on #6359.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread packages/core/src/js/tracing/integrations/appStart.ts
antonis added a commit that referenced this pull request Jun 26, 2026
…ture

`captureStandaloneAppStart` awaits native work before it marks the transaction
as sent, so a late `appLoaded()` racing an in-flight deferred capture could pass
the sent-guard and emit a second `app.start` transaction. Add an in-flight flag
set synchronously at entry (no await before it, so the check-and-set is atomic in
JS) and cleared in a `finally`, alongside the existing already-sent guard.

Surfaced by automated review on #6359.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit cafa472. Configure here.

antonis and others added 7 commits June 26, 2026 15:19
Add experimental `_experiments.enableStandaloneAppStartTracing` to send app
start as a dedicated `app.start` transaction (Span V2) instead of attaching
app start data to the first navigation (`ui.load`) transaction.

The standalone transaction uses op `app.start`, name `App Start`, and carries
the vitals as attributes on the root span (`app.vitals.start.value`,
`app.vitals.start.type`). This decouples app start from the navigation
transaction lifecycle, so it is no longer lost when no qualifying navigation
transaction is sent. The legacy (non-standalone) path is unchanged and remains
the default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add hermetic snapshot tests that byte-lock the full event for both the default
(non-standalone) and opt-in standalone cold app start flows. The default
snapshot was generated against the pre-change SDK and matches unchanged after
the standalone changes, proving the non-opt-in path is unaffected. The
standalone snapshot guards the Span V2 encoding (op `app.start`,
`app.vitals.start.*`, no legacy per-type span/measurement).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The MAX_APP_START_AGE_MS bounds check compared the native app start time against
`event.start_timestamp`, which for a standalone transaction still holds the span
creation time at that point (it is corrected to the native app start time later).
On slow devices that gap can exceed the threshold and discard a valid app start.
The age check only makes sense for the non-standalone path (where app start is
attached to a later navigation transaction), so skip it for standalone; the
duration check still filters genuinely bogus app starts.

Surfaced by automated review on #6359.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In standalone mode the deferred auto-capture (`setTimeout(0)`) is meant to be
cancelled by a later `appLoaded()` call. But once the deferred send has fired,
`cancelDeferredStandaloneCapture()` is a no-op and `_appLoaded()` reset the
flushed flag and sent a second `app.start` transaction for the same app run
(the common case, since apps signal readiness in a later macrotask).

Track whether a standalone transaction has already been sent and skip
re-sending; reset the flag on `runApplication` so a new app run can send again.

Surfaced by automated review on #6359.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ture

`captureStandaloneAppStart` awaits native work before it marks the transaction
as sent, so a late `appLoaded()` racing an in-flight deferred capture could pass
the sent-guard and emit a second `app.start` transaction. Add an in-flight flag
set synchronously at entry (no await before it, so the check-and-set is atomic in
JS) and cleared in a `finally`, alongside the existing already-sent guard.

Surfaced by automated review on #6359.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Trim the synthetic breakdown-parent to the fields the span helpers actually read
  (op/origin/span_id/trace_id/start_timestamp/data); drop the inert ones.
- Gate the carrier-transaction end-timestamp clamp to non-standalone, since the
  standalone path sets its end timestamp explicitly.

No behavior change — the default and standalone event snapshots are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@antonis antonis force-pushed the antonislilis/rn-541-feattracing-decouple-app-start-data-from-navigation branch from cafa472 to 3ddd44c Compare June 26, 2026 13:26
@antonis antonis added the ready-to-merge Triggers the full CI test suite label Jun 26, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3ddd44c. Configure here.

firstStartedActiveRootSpan = undefined;
isAppLoadedManuallyInvoked = false;
cachedNativeAppStart = undefined;
standaloneAppStartSent = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remount blocks standalone app start

Medium Severity

With standalone tracing, standaloneAppStartSent is cleared only inside the runApplication handler when appStartDataFlushed is already true. A second runApplication before the first standalone capture finishes leaves that flag set after the first send, so the remount’s _captureAppStart hits the guard and never emits an app.start transaction for the new run.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3ddd44c. Configure here.

Comment on lines 338 to 344
firstStartedActiveRootSpan = undefined;
isAppLoadedManuallyInvoked = false;
cachedNativeAppStart = undefined;
standaloneAppStartSent = false;
if (deferredStandaloneTimeout !== undefined) {
clearTimeout(deferredStandaloneTimeout);
deferredStandaloneTimeout = undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: During a hot reload, if a previous app start capture is in progress, the standaloneCaptureInProgress flag is not reset, causing the new app start transaction to be silently missed.
Severity: MEDIUM

Suggested Fix

Ensure the standaloneCaptureInProgress flag is reset in all relevant state-clearing paths within onRunApplication. Specifically, add standaloneCaptureInProgress = false; to the else block that handles cases where appStartDataFlushed is false to prevent the race condition on app restart.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/core/src/js/tracing/integrations/appStart.ts#L338-L344

Potential issue: A race condition can occur if the application restarts, for example via
hot reload, while a standalone app start capture is in progress and awaiting
`fetchNativeFramesDelay`. The `onRunApplication` callback for the new run will execute
while `appStartDataFlushed` is `false`, taking a path that fails to reset the
`standaloneCaptureInProgress` flag. Consequently, the new app run's attempt to capture
its own app start will see the flag is still `true` from the previous run and will
return early, permanently and silently missing the app start transaction for the new
run.

@sentry

sentry Bot commented Jun 26, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
Sentry RN io.sentry.reactnative.sample 8.16.0 (94) Release

⚙️ sentry-react-native Build Distribution Settings

@github-actions

Copy link
Copy Markdown
Contributor

iOS (legacy) Performance metrics 🚀

  Plain With Sentry Diff
Startup time 3832.21 ms 1216.71 ms -2615.50 ms
Size 4.98 MiB 6.51 MiB 1.53 MiB

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
6176a94+dirty 3836.50 ms 1217.64 ms -2618.86 ms
44c8b3f+dirty 3823.85 ms 1207.66 ms -2616.19 ms
64630e5+dirty 3842.70 ms 1218.11 ms -2624.60 ms
5257d80+dirty 3854.39 ms 1234.28 ms -2620.11 ms
3b6e9f9+dirty 3851.90 ms 1233.33 ms -2618.57 ms
88735e9+dirty 3837.02 ms 1214.40 ms -2622.62 ms
37a2091+dirty 3821.77 ms 1212.34 ms -2609.43 ms
5a010b7+dirty 3838.85 ms 1214.98 ms -2623.87 ms
68672fc+dirty 3841.58 ms 1228.89 ms -2612.69 ms
f3215d3+dirty 3842.73 ms 1219.33 ms -2623.40 ms

App size

Revision Plain With Sentry Diff
6176a94+dirty 5.15 MiB 6.68 MiB 1.53 MiB
44c8b3f+dirty 5.15 MiB 6.66 MiB 1.51 MiB
64630e5+dirty 4.98 MiB 6.46 MiB 1.49 MiB
5257d80+dirty 5.15 MiB 6.69 MiB 1.54 MiB
3b6e9f9+dirty 5.15 MiB 6.68 MiB 1.53 MiB
88735e9+dirty 4.98 MiB 6.46 MiB 1.49 MiB
37a2091+dirty 5.15 MiB 6.70 MiB 1.54 MiB
5a010b7+dirty 5.15 MiB 6.69 MiB 1.54 MiB
68672fc+dirty 5.15 MiB 6.71 MiB 1.55 MiB
f3215d3+dirty 5.15 MiB 6.67 MiB 1.52 MiB

@github-actions

Copy link
Copy Markdown
Contributor

Android (legacy) Performance metrics 🚀

  Plain With Sentry Diff
Startup time 465.54 ms 549.85 ms 84.31 ms
Size 49.74 MiB 54.85 MiB 5.11 MiB

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
5257d80+dirty 423.37 ms 467.54 ms 44.17 ms
1e5d96d+dirty 519.43 ms 543.62 ms 24.19 ms
4e0ba9c+dirty 452.84 ms 473.36 ms 20.52 ms
4e0b819+dirty 420.56 ms 470.08 ms 49.52 ms
a50b33d+dirty 500.81 ms 532.11 ms 31.30 ms
ad66da3+dirty 468.46 ms 533.56 ms 65.10 ms
37a2091+dirty 407.82 ms 441.22 ms 33.40 ms
68ae91b+dirty 416.44 ms 477.56 ms 61.12 ms
15d4514+dirty 406.77 ms 428.06 ms 21.29 ms
5a21b51+dirty 471.42 ms 524.22 ms 52.80 ms

App size

Revision Plain With Sentry Diff
5257d80+dirty 48.30 MiB 53.58 MiB 5.28 MiB
1e5d96d+dirty 49.74 MiB 54.81 MiB 5.07 MiB
4e0ba9c+dirty 48.30 MiB 53.49 MiB 5.19 MiB
4e0b819+dirty 49.74 MiB 54.81 MiB 5.07 MiB
a50b33d+dirty 43.75 MiB 48.08 MiB 4.33 MiB
ad66da3+dirty 48.30 MiB 53.49 MiB 5.19 MiB
37a2091+dirty 48.30 MiB 53.58 MiB 5.28 MiB
68ae91b+dirty 49.74 MiB 54.79 MiB 5.05 MiB
15d4514+dirty 48.30 MiB 53.60 MiB 5.30 MiB
5a21b51+dirty 48.30 MiB 53.49 MiB 5.19 MiB

Comment on lines +434 to +438
if (standaloneAppStartSent || standaloneCaptureInProgress) {
// A standalone transaction was already sent for this app run, or a capture is already in
// flight (e.g. the deferred auto-capture is awaiting native work when a late appLoaded()
// arrives). Either way, don't start a second send.
debug.log('[AppStart] Standalone app start capture already sent or in progress. Skipping.');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resetAppStartDataFlushed does not reset standaloneAppStartSent, silently dropping the appLoaded() manual timestamp

When appLoaded() calls resetAppStartDataFlushed() to allow a re-send with the manual timestamp, the guard standaloneAppStartSent is not cleared, so captureStandaloneAppStart() immediately returns early and the manual end timestamp is silently lost.

Evidence
  • appLoaded() at line 128 calls integration.resetAppStartDataFlushed() with the comment "Reset the flag so captureStandaloneAppStart can re-send with the manual timestamp".
  • resetAppStartDataFlushed (line 736) only resets appStartDataFlushed = false; it does not touch standaloneAppStartSent.
  • If the deferred auto-capture timeout (set to 0 ms in scheduleDeferredStandaloneCapture) fired before appLoaded() reached line 125, standaloneAppStartSent is already true.
  • The guard at lines 434–438 returns early when standaloneAppStartSent is true, so the manual-timestamp transaction is never sent despite the documented intent.
  • The onRunApplication reset at line 341 does clear standaloneAppStartSent, but that only runs on the next app launch, not during the current appLoaded() call.

Identified by Warden find-bugs · SEJ-EBM

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

iOS (new) Performance metrics 🚀

  Plain With Sentry Diff
Startup time 3838.67 ms 1213.69 ms -2624.98 ms
Size 4.98 MiB 6.50 MiB 1.52 MiB

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
0b1b5e3+dirty 3820.72 ms 1207.94 ms -2612.78 ms
5125c43+dirty 3827.94 ms 1208.79 ms -2619.15 ms
7887847+dirty 3844.89 ms 1221.67 ms -2623.22 ms
acd838e+dirty 3835.94 ms 1215.87 ms -2620.07 ms
20fbd51+dirty 3832.52 ms 1206.13 ms -2626.39 ms
a5d243c+dirty 3827.92 ms 1220.10 ms -2607.81 ms
a0d8cf8+dirty 3826.15 ms 1213.12 ms -2613.03 ms
a3265b6+dirty 3844.26 ms 1235.60 ms -2608.66 ms
3817909+dirty 1210.76 ms 1215.64 ms 4.89 ms
b04af96+dirty 3830.54 ms 1206.11 ms -2624.44 ms

App size

Revision Plain With Sentry Diff
0b1b5e3+dirty 5.15 MiB 6.70 MiB 1.54 MiB
5125c43+dirty 5.15 MiB 6.68 MiB 1.53 MiB
7887847+dirty 4.98 MiB 6.46 MiB 1.48 MiB
acd838e+dirty 5.15 MiB 6.70 MiB 1.55 MiB
20fbd51+dirty 4.98 MiB 6.46 MiB 1.49 MiB
a5d243c+dirty 5.15 MiB 6.68 MiB 1.53 MiB
a0d8cf8+dirty 5.15 MiB 6.67 MiB 1.51 MiB
a3265b6+dirty 5.15 MiB 6.68 MiB 1.53 MiB
3817909+dirty 3.38 MiB 4.73 MiB 1.35 MiB
b04af96+dirty 4.98 MiB 6.54 MiB 1.56 MiB

@github-actions

Copy link
Copy Markdown
Contributor

Android (new) Performance metrics 🚀

  Plain With Sentry Diff
Startup time 418.21 ms 464.18 ms 45.97 ms
Size 49.74 MiB 54.85 MiB 5.11 MiB

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
3ce5254+dirty 373.90 ms 427.84 ms 53.94 ms
c004dae+dirty 404.60 ms 430.67 ms 26.07 ms
853723c+dirty 415.82 ms 460.94 ms 45.12 ms
7ff4d0f+dirty 403.38 ms 427.06 ms 23.68 ms
038a6d7+dirty 499.02 ms 527.68 ms 28.66 ms
09a902f+dirty 423.02 ms 472.18 ms 49.16 ms
c2e182c+dirty 468.50 ms 545.44 ms 76.94 ms
71abba0+dirty 411.04 ms 453.67 ms 42.63 ms
57e0069+dirty 442.25 ms 486.64 ms 44.39 ms
0bd8916+dirty 400.15 ms 442.72 ms 42.57 ms

App size

Revision Plain With Sentry Diff
3ce5254+dirty 43.94 MiB 48.98 MiB 5.04 MiB
c004dae+dirty 48.30 MiB 53.49 MiB 5.19 MiB
853723c+dirty 48.30 MiB 53.58 MiB 5.28 MiB
7ff4d0f+dirty 48.30 MiB 53.60 MiB 5.30 MiB
038a6d7+dirty 48.30 MiB 53.60 MiB 5.30 MiB
09a902f+dirty 49.74 MiB 54.81 MiB 5.07 MiB
c2e182c+dirty 49.74 MiB 54.85 MiB 5.11 MiB
71abba0+dirty 48.30 MiB 53.49 MiB 5.19 MiB
57e0069+dirty 49.74 MiB 54.85 MiB 5.11 MiB
0bd8916+dirty 48.30 MiB 53.57 MiB 5.26 MiB

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-merge Triggers the full CI test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(tracing): Decouple app start data from navigation transaction lifecycle

1 participant