feat(tracing): Add standalone app start transaction#6359
Conversation
Semver Impact of This PR⚪ None (no version bump detected) 📋 Changelog PreviewThis is how your changes will appear in the changelog.
🤖 This preview updates automatically when you update the PR. |
|
There was a problem hiding this comment.
✅ 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.
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>
There was a problem hiding this comment.
✅ 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.
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>
cafa472 to
3ddd44c
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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; |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 3ddd44c. Configure here.
| firstStartedActiveRootSpan = undefined; | ||
| isAppLoadedManuallyInvoked = false; | ||
| cachedNativeAppStart = undefined; | ||
| standaloneAppStartSent = false; | ||
| if (deferredStandaloneTimeout !== undefined) { | ||
| clearTimeout(deferredStandaloneTimeout); | ||
| deferredStandaloneTimeout = undefined; |
There was a problem hiding this comment.
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.
📲 Install BuildsAndroid
|
iOS (legacy) Performance metrics 🚀
|
| 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 |
Android (legacy) Performance metrics 🚀
|
| 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 |
| 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.'); |
There was a problem hiding this comment.
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 callsintegration.resetAppStartDataFlushed()with the comment "Reset the flag socaptureStandaloneAppStartcan re-send with the manual timestamp".resetAppStartDataFlushed(line 736) only resetsappStartDataFlushed = false; it does not touchstandaloneAppStartSent.- If the deferred auto-capture timeout (set to
0 msinscheduleDeferredStandaloneCapture) fired beforeappLoaded()reached line 125,standaloneAppStartSentis alreadytrue. - The guard at lines 434–438 returns early when
standaloneAppStartSentistrue, so the manual-timestamp transaction is never sent despite the documented intent. - The
onRunApplicationreset at line 341 does clearstandaloneAppStartSent, but that only runs on the next app launch, not during the currentappLoaded()call.
Identified by Warden find-bugs · SEJ-EBM
iOS (new) Performance metrics 🚀
|
| 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 |
Android (new) Performance metrics 🚀
|
| 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 |


📢 Type of change
📜 Description
Adds an experimental, opt-in flag
_experiments.enableStandaloneAppStartTracingthat sends app start as a dedicatedapp.starttransaction instead of attaching app start data to the first navigation (ui.load) transaction.💡 Motivation and Context
Closes #5839.
💚 How did you test it?
appStart.test.tsmigrated to the V2 encoding (opapp.start, vitals attributes, no legacy span/measurement); breakdown-span, frame-data, andappLoaded()standalone tests updated accordingly.app.starttransaction sharestrace_idwith the navigation transaction.defaultAppStart.test.ts: verifies the flag wiresstandaloneon/off/default.MAX_APP_START_AGE_MSis still sent), with the non-standalone path still asserting the age check applies.appLoaded()arrives, and (b)appLoaded()races an in-flight deferred capture → in both cases only oneapp.starttransaction is sent; verified each fails before the fix (2 transactions) and passes after.📝 Checklist
sendDefaultPIIis enabled🔮 Next steps