feat(flutter): Extend standalone app start - #3918
Conversation
Semver Impact of This PR🟡 Minor (new features) 📋 Changelog PreviewThis is how your changes will appear in the changelog. FeaturesFlutter
Dependencies
Internal Changes
🤖 This preview updates automatically when you update the PR. |
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## feat/standalone-app-start-new #3918 +/- ##
=================================================================
+ Coverage 87.78% 87.92% +0.14%
=================================================================
Files 346 346
Lines 12655 12854 +199
=================================================================
+ Hits 11109 11302 +193
- Misses 1546 1552 +6
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. |
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
|
@cursor review |
There was a problem hiding this comment.
Pull request overview
Adds a public SentryFlutter API surface to extend standalone App Start and attach child spans, with support for both static (V1 span) and streaming (V2 span) trace lifecycles. This builds on the standalone app-start tracing subsystem by allowing an explicit “extended” region and exposing its span handle(s) for downstream instrumentation.
Changes:
- Adds
SentryFlutter.extendAppStart(),finishExtendedAppStart(), and lifecycle-specific getters for the extended span (V1/V2). - Implements extended-span lifecycle, measurement endpoint selection, and close/deadline behaviors for both static and streaming standalone app-start traces.
- Adds comprehensive tests + docs + example usage for the new extended app start flow.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/flutter/lib/src/sentry_flutter.dart | Adds the new public APIs and span getters for extended standalone app start. |
| packages/flutter/lib/src/sentry_flutter_options.dart | Publishes an internal standaloneAppStartTrace reference for the public APIs to access. |
| packages/flutter/lib/src/app_start/standalone/app_start_trace.dart | Extends the internal trace contract with extend/finish/getter APIs and standaloneExtendedAppStartName. |
| packages/flutter/lib/src/app_start/standalone/static_app_start_trace.dart | Implements extended-span creation/finish and measurement endpoint selection for static lifecycle. |
| packages/flutter/lib/src/app_start/standalone/streaming_app_start_trace.dart | Implements extended-span creation/finish, V2 lifecycle hooks, and measurement endpoint selection for streaming lifecycle. |
| packages/flutter/lib/src/app_start/standalone/standalone_app_start_lifecycle.dart | Publishes/clears the active standalone trace on options so SentryFlutter APIs can access it. |
| packages/flutter/test/sentry_flutter_test.dart | Tests the new public APIs (timestamps forwarded, getters, no-op behavior). |
| packages/flutter/test/app_start_trace_test_support.dart | Adds a lightweight AppStartTrace test double for API-level tests. |
| packages/flutter/test/app_start/standalone/static_app_start_trace_test.dart | Adds extensive static-lifecycle coverage for extension span behavior, measurement endpoint, and deadlines. |
| packages/flutter/test/app_start/standalone/streaming_app_start_trace_test.dart | Adds extensive streaming-lifecycle coverage for extension span behavior, measurement endpoint, and deadlines/close. |
| packages/flutter/test/app_start/standalone/standalone_app_start_lifecycle_test.dart | Verifies lifecycle publishing/cleanup and parity between static vs streaming extension outputs. |
| packages/flutter/example/lib/main.dart | Demonstrates using the extended span in both static and streaming lifecycles. |
| packages/dart/lib/src/sdk_lifecycle_hooks.dart | Adds prepend: option to lifecycle callback registration to control callback ordering. |
| packages/dart/test/sdk_lifecycle_hooks_test.dart | Tests prepended callback execution order. |
| packages/dart/lib/src/constants.dart | Adds app.start.extended op constant and a cancelled status-message constant. |
| docs/standalone-app-start-spec.md | Documents the extended app start APIs and lifecycle behaviors. |
Comments suppressed due to low confidence (1)
packages/flutter/lib/src/sentry_flutter.dart:414
- Same concern as extendAppStart(): this should behave as a no-op when inactive, but options.clock() / trace.finishExtended(...) can throw and bubble up to the app. Wrapping in try/catch keeps the API safe and consistent with its no-op semantics.
static Future<void> finishExtendedAppStart() async {
final options = Sentry.currentHub.options;
if (options is SentryFlutterOptions) {
final trace = options.standaloneAppStartTrace;
if (trace != null) {
await trace.finishExtended(options.clock());
}
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
4fd5022 to
63f32b0
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 1403674. Configure here.
Add lifecycle-specific APIs for extending standalone app start while preserving equivalent static and streaming span behavior. An app start with a pending extension reports no duration until the extension completes, and reports up to the extension end once it does. Refs GH-3767 Co-Authored-By: GPT-5.6 Sol <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com>
The extension entry points are documented to run inside appRunner, where an escaping throw would leave the host app unstarted. Span creation and lookup now stay behind a boundary that logs and degrades instead. Co-Authored-By: Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the deadline outcome on an extension closed while the final-deadline drain is still running, instead of downgrading it to success. Release the published trace once the app start has reported, so a finished tracer and its children are no longer retained for the process lifetime. Tag the streaming extension span with the app-start type every other child of the root already carries. Rename the extension outcome flag to isSettled, document the interface members, and record why the two lifecycles resolve completion and status differently. Co-Authored-By: Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
extendAppStart() returns nothing, so a refusal was invisible: an unsupported platform, a first frame that already rendered, or an app start that is already extended all looked identical to success. Each now says why through the internal logger, and the dartdoc spells out that an extension left unfinished holds the app start open until its deadline and costs it the duration. Fold the extension endpoint into AppStartVitals.resolve as well. Both lifecycles used to pre-resolve it before calling in, which put the reporting rule back in the two places that file exists to keep it out of. Co-Authored-By: Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
TestAppStartTrace always accepted the extension, so nothing exercised what the public API does when a trace turns one down. Co-Authored-By: Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
tryExtend logged every refusal while finishExtended returned silently, though a refused finish is the more useful of the two to hear about: it happens once the app start has already been reported, so the user is looking at a duration the extension was meant to cover. Also explain the two places the static and streaming lifecycles diverge on purpose - the missing _finalizing guard, and why the two extension lifecycle classes are not shared. Co-Authored-By: Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
The unpublish-on-complete test only ran the static fixture, so the streaming lifecycle's onCompleted call site had no coverage. Name the pair after their lifecycles now that both exist. Co-Authored-By: Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
refuseAppStartExtension read as though it performed the refusal when it only logged and handed back the value the caller returned. Split the logging from the control flow so each line does one thing. Also rename completionSnapshot to outcome, matching its AppStartExtensionOutcome type and dropping "completion" for a value that also covers never having been extended, and move the extension name constant onto the standaloneAppStart* prefix its siblings use. Co-Authored-By: Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Assert the literal op, description and origin instead of the production constants the code reads, so the test can disagree with a wrong constant. Guard the streaming close test against a null close future, which awaited to a no-op and passed even if close() never ran. Co-Authored-By: Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Closing flushed the tracer children in the order they were stored, so a nested extension descendant could end after the parent it belongs to. The deadline drain already reverses for this reason; do the same here. Co-Authored-By: Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
The root hitting its 30s deadline dropped the duration outright, so an extension that settled normally lost its measurement whenever a leftover descendant held the root open. A never-finished extension lost it too. Measure to the extension only when it settled on its own, and fall back to the first frame otherwise. sentry-java and sentry-cocoa clamp a timed-out time-to-full-display back to time-to-initial-display rather than withholding it; this matches. The duration is now absent only when no first frame arrived. Co-Authored-By: Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Reviewer-facing cleanup of the extended app start. No behavior change. The handler held the published options behind a name and a guard that implied someone else could publish the trace. Only the handler writes that field, so it is now plainly the options start was given, and the test for the guard goes with it. _finishAtDeadline still claimed the vitals omit the duration once the root is past its deadline. That stopped being true when the duration began falling back to the first frame. The public entry points now call the refusal log helpers instead of interpolating the prefix by hand, which drops both prefix constants. _finishSpan loses an argument close() could never reach through it, _deadlineStamped becomes _settledAfterDeadline to match what it records, and the interface says why there is one extension span getter per span protocol. Co-Authored-By: Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
SDK close force-ends a running extension at the teardown clock, and that endpoint still reached the app-start measurement — so closing minutes after the first frame reported the duration to whenever close ran instead of to the frame. Withhold it the way a deadline force-end is already withheld, so the duration falls back to the first frame. _settledAfterDeadline becomes _forceEnded now that it records both teardowns. Co-Authored-By: Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
The later of the first frame and the extension won the measurement before plausibility was asked, so an extension pushing the window past the 60s ceiling dropped the duration even when the first frame was still inside it. The two windows are anchored differently: the extension runs on a budget measured from trace creation, which a pre-warmed launch can enter up to the ceiling after the process started. So extending could report nothing where not extending reported a duration. Ask plausibility per endpoint and fall back to the first frame, the way a force-ended extension already does. Also name the vitals input firstFrameTimestamp, since that is the only thing it ever carries, and reword the _finalizing comment that read as if the flag were scoped to the deadline sweep rather than latched. Co-Authored-By: Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
The handler kept its own reference to the trace it also publishes on the options. Read the published one instead. close() reads it into a local before awaiting, because closing a static trace reports it, and reporting unpublishes the field mid-teardown. Co-Authored-By: Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
The example split the extension across main() and the widget's initState, handing the startup work between them through a top-level future. Do all of it in initState, which runs in the first frame's build phase — ahead of the raster-finish timings callback that closes the window — so the top-level future goes away. Co-Authored-By: Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
28d7f42 to
e775410
Compare

📜 Description
Experimental
SentryFlutterAPI for extending the standalone App Start past the first frame, so startup work that finishes later is part of the reported duration.extendAppStart()opens oneapp.start.extendedchild of theApp Startroot and holds the root open.finishExtendedAppStart()closes it and releases the app start to report, measuring to the extension end when it outlasts the first frame. While an extension is still open the duration is withheld, rather than reporting a window that ends mid-extension.To nest work under the extension, take the span itself:
getExtendedAppStartSpan()for the V1ISentrySpanon the static lifecycle,getExtendedAppStartSpanV2()for the V2SentrySpanV2on the streaming one — two getters because the protocols don't merge into anything worth exposing publicly. Each returnsnullon the other lifecycle and once the extension has ended. Finishing the span completes the extension on its own.Requires
enableStandaloneAppStartTracingon iOS or Android. All entry points are guarded at the boundary so an SDK bug can't throw intoappRunner, and refusals are logged rather than returned. Operation and method names are verbatim from sentry-java and sentry-cocoa.Note
Stacked on #3896 — that is the base branch and should be read first.
💡 Motivation and Context
Standalone App Start measures process start to first frame, so work finishing after it — remote config, auth restore, flag hydration — falls outside the window and understates startup. This lets the app say where startup really ends.
Closes #3767
💚 How did you test it?
Unit coverage on both lifecycles: hierarchy and status, nested descendants, the deadline interaction, finishing the span directly instead of via the API, refused extensions and refused finishes, vitals endpoint resolution, and unpublish-on-complete. The example app is wired up end to end.
📝 Checklist
sendDefaultPiiis enabled🔮 Next steps
Made with Cursor