Skip to content

feat(flutter): Extend standalone app start - #3918

Merged
buenaflor merged 16 commits into
feat/standalone-app-start-newfrom
feat/extended-app-start-new
Jul 28, 2026
Merged

feat(flutter): Extend standalone app start#3918
buenaflor merged 16 commits into
feat/standalone-app-start-newfrom
feat/extended-app-start-new

Conversation

@buenaflor

@buenaflor buenaflor commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

📜 Description

Experimental SentryFlutter API for extending the standalone App Start past the first frame, so startup work that finishes later is part of the reported duration.

appRunner: () async {
  SentryFlutter.extendAppStart();
  runApp(const MyApp());

  try {
    await completeStartupWork();
  } finally {
    await SentryFlutter.finishExtendedAppStart();
  }
},

extendAppStart() opens one app.start.extended child of the App Start root 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 V1 ISentrySpan on the static lifecycle, getExtendedAppStartSpanV2() for the V2 SentrySpanV2 on the streaming one — two getters because the protocols don't merge into anything worth exposing publicly. Each returns null on the other lifecycle and once the extension has ended. Finishing the span completes the extension on its own.

Requires enableStandaloneAppStartTracing on iOS or Android. All entry points are guarded at the boundary so an SDK bug can't throw into appRunner, 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

  • I reviewed submitted code
  • 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
  • All tests passing
  • Public API changes reviewed by another Mobile SDK team member or implemented according to the develop docs spec
  • No breaking changes

🔮 Next steps

Made with Cursor

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

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


Features

Flutter

  • Extend standalone app start by buenaflor in #3918
  • Add standalone app start tracing by buenaflor in #3896

Dependencies

  • chore(deps): update Android SDK to v8.50.1 by github-actions in #3921

Internal Changes

  • (deps) Pin Flutter development dependencies by buenaflor in #3913
  • (grpc) Move MockHub to _sentry_testing package by lucas-zimerman in #3908

🤖 This preview updates automatically when you update the PR.

@buenaflor

This comment was marked as outdated.

@buenaflor

This comment was marked as outdated.

Comment thread packages/flutter/lib/src/app_start/standalone/static_app_start_trace.dart Outdated
Comment thread packages/flutter/lib/src/app_start/standalone/streaming_app_start_trace.dart Outdated
Comment thread packages/flutter/lib/src/app_start/standalone/static_app_start_trace.dart Outdated
@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.18692% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.92%. Comparing base (784dceb) to head (e775410).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...pp_start/standalone/streaming_app_start_trace.dart 86.41% 11 Missing ⚠️
...c/app_start/standalone/static_app_start_trace.dart 93.18% 6 Missing ⚠️
packages/flutter/lib/src/sentry_flutter.dart 83.33% 4 Missing ⚠️
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     
Flag Coverage Δ
sentry 87.62% <ø> (ø)
sentry_dio 97.73% <ø> (ø)
sentry_drift 93.57% <ø> (ø)
sentry_file 65.29% <ø> (ø)
sentry_firebase_remote_config 100.00% <ø> (ø)
sentry_flutter 92.17% <90.18%> (+0.24%) ⬆️
sentry_grpc 99.09% <ø> (ø)
sentry_hive 77.48% <ø> (ø)
sentry_isar 74.37% <ø> (ø)
sentry_link 21.50% <ø> (ø)
sentry_logging 97.01% <ø> (ø)
sentry_sqflite 88.81% <ø> (ø)
sentry_supabase 97.27% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@buenaflor

This comment was marked as outdated.

@buenaflor

This comment was marked as outdated.

@buenaflor

This comment was marked as outdated.

@buenaflor

This comment was marked as outdated.

Comment thread packages/flutter/lib/src/app_start/standalone/static_app_start_trace.dart Outdated
@buenaflor

Copy link
Copy Markdown
Contributor Author

@cursor review

@buenaflor
buenaflor marked this pull request as ready for review July 23, 2026 09:54
@buenaflor
buenaflor requested a review from denrase as a code owner July 23, 2026 09:54
Copilot AI review requested due to automatic review settings July 23, 2026 09:54
Comment thread packages/flutter/lib/src/app_start/standalone/static_app_start_trace.dart Outdated

Copilot AI left a comment

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.

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.

Comment thread packages/flutter/lib/src/sentry_flutter.dart Outdated
Comment thread docs/standalone-app-start-spec.md Outdated
Comment thread packages/flutter/lib/src/sentry_flutter.dart
Comment thread packages/flutter/lib/src/app_start/standalone/static_app_start_trace.dart Outdated
Comment thread packages/flutter/lib/src/app_start/standalone/static_app_start_trace.dart Outdated
Comment thread packages/flutter/lib/src/app_start/standalone/static_app_start_trace.dart Outdated
Comment thread packages/flutter/lib/src/app_start/standalone/static_app_start_trace.dart Outdated
Comment thread packages/flutter/lib/src/app_start/standalone/streaming_app_start_trace.dart Outdated
Comment thread packages/flutter/lib/src/app_start/standalone/streaming_app_start_trace.dart Outdated
Comment thread packages/flutter/lib/src/app_start/standalone/streaming_app_start_trace.dart Outdated
Comment thread packages/flutter/lib/src/app_start/standalone/static_app_start_trace.dart Outdated
Comment thread packages/flutter/test/app_start/standalone/static_app_start_trace_test.dart Outdated
@buenaflor
buenaflor force-pushed the feat/extended-app-start-new branch from 4fd5022 to 63f32b0 Compare July 27, 2026 12:43
Comment thread packages/flutter/lib/src/app_start/standalone/static_app_start_trace.dart Outdated
Comment thread packages/flutter/lib/src/app_start/standalone/app_start_vitals.dart Outdated

@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 1403674. Configure here.

Comment thread packages/flutter/lib/src/app_start/standalone/app_start_vitals.dart Outdated
Comment thread packages/flutter/example/lib/main.dart Outdated
Comment thread packages/flutter/lib/src/app_start/standalone/app_start_vitals.dart Outdated
Comment thread packages/flutter/lib/src/app_start/standalone/app_start_vitals.dart Outdated
Comment thread packages/flutter/lib/src/app_start/standalone/standalone_app_start_handler.dart Outdated
buenaflor and others added 16 commits July 28, 2026 16:24
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>
@buenaflor
buenaflor force-pushed the feat/extended-app-start-new branch from 28d7f42 to e775410 Compare July 28, 2026 14:24
@buenaflor
buenaflor merged commit 3f5a205 into main Jul 28, 2026
158 checks passed
@buenaflor
buenaflor deleted the feat/extended-app-start-new branch July 28, 2026 14:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add API to extend app start (Flutter)

3 participants