Skip to content
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e7d39c8
feat(flutter): Add TimeToDisplayTrackerV2 for streaming TTID/TTFD
buenaflor Feb 19, 2026
ab04b07
refactor(flutter): Wire TimeToDisplayTrackerV2 through the system
buenaflor Feb 19, 2026
f3b0bcb
refactor(flutter): Improve TimeToDisplayTrackerV2 wiring and add stre…
buenaflor Feb 19, 2026
44dcb96
Update logger and example
buenaflor Feb 19, 2026
c0f9d6d
Review
buenaflor Feb 20, 2026
14d6df2
Review
buenaflor Feb 20, 2026
9d03dee
Review
buenaflor Feb 20, 2026
4d81a0f
Fix test
buenaflor Feb 20, 2026
027b043
Review
buenaflor Feb 20, 2026
fcaa139
Merge branch 'feat/span-first' into feat/span/ttid-ttfd
buenaflor Feb 20, 2026
414f04c
Update
buenaflor Feb 20, 2026
4a8b74b
Update
buenaflor Feb 20, 2026
7768434
review
buenaflor Feb 20, 2026
478f9ec
review
buenaflor Feb 20, 2026
24f154a
feat(tracing): Port native app start integration to V2 span API
buenaflor Feb 20, 2026
fe07028
Update
buenaflor Feb 23, 2026
431c3e1
Merge branch 'feat/span/ttid-ttfd' into feat/span/native-app-start-v2
buenaflor Feb 23, 2026
0f29921
feat(tracing): Early idle span creation for native app start parenting
buenaflor Feb 23, 2026
1f99f25
Merge branch 'feat/span-first' of github.com:getsentry/sentry-dart in…
buenaflor Feb 23, 2026
1d8cf1e
Update
buenaflor Feb 24, 2026
df04c88
test(flutter): Update TimeToDisplayTrackerV2 tests for new API
buenaflor Feb 24, 2026
be373fe
Update
buenaflor Feb 24, 2026
4e72478
feat(flutter): Refine native app start integration and clean up tests
buenaflor Feb 24, 2026
3314ac6
Update
buenaflor Feb 24, 2026
1c04eeb
ref(flutter): Clean up NativeAppStartHandlerV2
buenaflor Feb 24, 2026
271ed31
test(flutter): Remove redundant TimeToDisplayTrackerV2 tests
buenaflor Feb 24, 2026
fa63805
ref(flutter): Use fake tracker in navigator observer streaming tests
buenaflor Feb 24, 2026
d38be48
Update
buenaflor Feb 24, 2026
312d972
ref(flutter): Enhance error logging in native app start data and refi…
buenaflor Feb 24, 2026
352ebfd
Formatting
buenaflor Feb 24, 2026
b62b133
fix: Address review feedback for native app start v2
buenaflor Feb 25, 2026
7ccb5f7
fix: Apply dart format to handler files
buenaflor Feb 25, 2026
353a081
fix: Cancel prepared root navigation on early return and use exhausti…
buenaflor Feb 25, 2026
1365f84
fix: Normalize startTimestamp to UTC in span constructor
buenaflor Feb 25, 2026
96dfe1f
test(dart): assert UTC-normalized start timestamps
buenaflor Feb 25, 2026
07e2909
Test
buenaflor Feb 25, 2026
4cbf46b
chore: Remove unused devtools_options.yaml files from drift and link …
buenaflor Feb 25, 2026
0466ec7
test(flutter): expect UTC-normalized app start span timestamps
buenaflor Feb 25, 2026
339c70d
test(flutter): clean up comments in integration tests
buenaflor Feb 25, 2026
99403b2
refactor(flutter): consolidate attributes for span tracking in Native…
buenaflor Mar 17, 2026
02a9bf3
fix(flutter): handle null context in native app start integration
buenaflor Mar 17, 2026
8c844d1
Cleanup tracker in handler
buenaflor Mar 17, 2026
d11c441
refactor(flutter): rename tracking methods for clarity and consistency
buenaflor Mar 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/dart/lib/src/hub.dart
Original file line number Diff line number Diff line change
Expand Up @@ -715,8 +715,12 @@ class Hub {
String name, {
SentrySpanV2? parentSpan = const UnsetSentrySpanV2(),
Map<String, SentryAttribute>? attributes,
DateTime? startTimestamp,
}) =>
_createSpan(name, parentSpan: parentSpan, attributes: attributes);
_createSpan(name,
parentSpan: parentSpan,
attributes: attributes,
startTimestamp: startTimestamp);

/// Returns `true` if the hub is able to create spans.
bool get _canCreateSpansV2 {
Expand Down Expand Up @@ -790,6 +794,7 @@ class Hub {
String name, {
SentrySpanV2? parentSpan = const UnsetSentrySpanV2(),
Map<String, SentryAttribute>? attributes,
DateTime? startTimestamp,
}) {
if (!_canCreateSpansV2) return NoOpSentrySpanV2.instance;

Expand Down Expand Up @@ -833,6 +838,7 @@ class Hub {
clock: options.clock,
dscCreator: _dscCreator,
samplingDecision: samplingDecision,
startTimestamp: startTimestamp,
);
} else {
span = RecordingSentrySpanV2.child(
Expand All @@ -841,6 +847,7 @@ class Hub {
onSpanEnd: captureSpan,
clock: options.clock,
dscCreator: _dscCreator,
startTimestamp: startTimestamp,
);
}

Expand All @@ -862,6 +869,7 @@ class Hub {
Duration finalTimeout = const Duration(seconds: 30),
bool trimIdleSpanEndTimestamp = true,
Map<String, SentryAttribute>? attributes,
DateTime? startTimestamp,
}) {
if (_currentIdleSpan != null) {
internalLogger.warning(
Expand All @@ -887,6 +895,7 @@ class Hub {
finalTimeout: finalTimeout,
trimEndTimestamp: trimIdleSpanEndTimestamp,
lifecycleRegistry: _options.lifecycleRegistry,
startTimestamp: startTimestamp,
);

if (attributes != null) {
Expand Down
4 changes: 4 additions & 0 deletions packages/dart/lib/src/hub_adapter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,13 @@ class HubAdapter implements Hub {
String name, {
SentrySpanV2? parentSpan = const UnsetSentrySpanV2(),
Map<String, SentryAttribute>? attributes,
DateTime? startTimestamp,
}) =>
Sentry.currentHub.startInactiveSpan(
name,
parentSpan: parentSpan,
attributes: attributes,
startTimestamp: startTimestamp,
);

@override
Expand Down Expand Up @@ -250,12 +252,14 @@ class HubAdapter implements Hub {
Duration finalTimeout = const Duration(seconds: 30),
bool trimIdleSpanEndTimestamp = true,
Map<String, SentryAttribute>? attributes,
DateTime? startTimestamp,
}) =>
Sentry.currentHub.startIdleSpan(
name,
idleTimeout: idleTimeout,
finalTimeout: finalTimeout,
trimIdleSpanEndTimestamp: trimIdleSpanEndTimestamp,
attributes: attributes,
startTimestamp: startTimestamp,
);
}
2 changes: 2 additions & 0 deletions packages/dart/lib/src/noop_hub.dart
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ class NoOpHub implements Hub {
String name, {
SentrySpanV2? parentSpan = const UnsetSentrySpanV2(),
Map<String, SentryAttribute>? attributes,
DateTime? startTimestamp,
}) =>
NoOpSentrySpanV2.instance;

Expand All @@ -187,6 +188,7 @@ class NoOpHub implements Hub {
Duration finalTimeout = const Duration(seconds: 30),
bool trimIdleSpanEndTimestamp = true,
Map<String, SentryAttribute>? attributes,
DateTime? startTimestamp,
}) =>
NoOpSentrySpanV2.instance;
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ final class IdleRecordingSentrySpanV2 extends RecordingSentrySpanV2 {
required this.finalTimeout,
required this.trimEndTimestamp,
required SdkLifecycleRegistry lifecycleRegistry,
super.startTimestamp,
}) : _lifecycleRegistry = lifecycleRegistry,
super._(parentSpan: null) {
_lifecycleRegistry.registerCallback<OnSpanStartV2>(_onSpanStartEvent);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ base class RecordingSentrySpanV2 implements SentrySpanV2 {
final RecordingSentrySpanV2? _parentSpan;
final ClockProvider _clock;
final OnSpanEndCallback _onSpanEnd;
final DateTime _startTimestamp;
final SentryId _traceId;
final RecordingSentrySpanV2? _segmentSpan;
final DscCreatorCallback _dscCreator;
final Map<String, SentryAttribute> _attributes = {};
final SentryTracesSamplingDecision _samplingDecision;

// Mutable span state.
DateTime _startTimestamp;
SentrySpanStatusV2 _status = SentrySpanStatusV2.ok;
DateTime? _endTimestamp;
String _name;
Expand All @@ -42,12 +42,13 @@ base class RecordingSentrySpanV2 implements SentrySpanV2 {
required RecordingSentrySpanV2? parentSpan,
required DscCreatorCallback dscCreator,
required SentryTracesSamplingDecision samplingDecision,
DateTime? startTimestamp,
}) : _traceId = parentSpan?.traceId ?? traceId,
_name = name,
_parentSpan = parentSpan,
_clock = clock,
_onSpanEnd = onSpanEnd,
_startTimestamp = clock(),
_startTimestamp = (startTimestamp ?? clock()).toUtc(),
_segmentSpan = parentSpan?.segmentSpan,
_dscCreator = dscCreator,
_samplingDecision = samplingDecision;
Expand All @@ -62,6 +63,7 @@ base class RecordingSentrySpanV2 implements SentrySpanV2 {
required ClockProvider clock,
required DscCreatorCallback dscCreator,
required SentryTracesSamplingDecision samplingDecision,
DateTime? startTimestamp,
}) {
return RecordingSentrySpanV2._(
traceId: traceId,
Expand All @@ -71,6 +73,7 @@ base class RecordingSentrySpanV2 implements SentrySpanV2 {
parentSpan: null,
dscCreator: dscCreator,
samplingDecision: samplingDecision,
startTimestamp: startTimestamp,
);
}

Expand All @@ -84,6 +87,7 @@ base class RecordingSentrySpanV2 implements SentrySpanV2 {
required OnSpanEndCallback onSpanEnd,
required ClockProvider clock,
required DscCreatorCallback dscCreator,
DateTime? startTimestamp,
}) {
return RecordingSentrySpanV2._(
traceId: parent.traceId,
Expand All @@ -93,6 +97,7 @@ base class RecordingSentrySpanV2 implements SentrySpanV2 {
parentSpan: parent,
dscCreator: dscCreator,
samplingDecision: parent.samplingDecision,
startTimestamp: startTimestamp,
);
}

Expand Down Expand Up @@ -120,6 +125,9 @@ base class RecordingSentrySpanV2 implements SentrySpanV2 {
@override
DateTime get startTimestamp => _startTimestamp;

@internal
set startTimestamp(DateTime value) => _startTimestamp = value.toUtc();

@override
DateTime? get endTimestamp => _endTimestamp;

Expand Down
48 changes: 48 additions & 0 deletions packages/dart/test/hub_span_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,39 @@ void main() {

expect(hub.scope.getActiveSpan(), isNull);
});

test('uses startTimestamp when provided', () {
final hub = fixture.getSut();
final past = DateTime(2024, 1, 1, 12, 0, 0);

final span = hub.startInactiveSpan('test-span', startTimestamp: past)
as RecordingSentrySpanV2;

expect(span.startTimestamp, equals(past.toUtc()));
});

test('uses clock when startTimestamp is not provided', () {
final hub = fixture.getSut();

final span =
hub.startInactiveSpan('test-span') as RecordingSentrySpanV2;

expect(span.startTimestamp, isNotNull);
expect(span.startTimestamp.difference(DateTime.now()).abs(),
lessThan(Duration(seconds: 1)));
});

test('child span uses startTimestamp when provided', () {
final hub = fixture.getSut();
final past = DateTime(2024, 1, 1, 12, 0, 0);

final root = hub.startInactiveSpan('root', parentSpan: null);
final child = hub.startInactiveSpan('child',
parentSpan: root, startTimestamp: past) as RecordingSentrySpanV2;

expect(child.startTimestamp, equals(past.toUtc()));
expect(child.startTimestamp.isUtc, isTrue);
});
});

group('when ignoreSpans rules are configured', () {
Expand Down Expand Up @@ -702,6 +735,21 @@ void main() {
});

group('when using idle spans', () {
test('uses startTimestamp when provided', () {
final hub = fixture.getSut();
final past = DateTime(2024, 1, 1, 12, 0, 0);

final idleSpan = hub.startIdleSpan(
'idle-root',
startTimestamp: past,
idleTimeout: Duration(seconds: 1),
finalTimeout: Duration(seconds: 2),
) as RecordingSentrySpanV2;

expect(idleSpan.startTimestamp, equals(past.toUtc()));
expect(idleSpan.startTimestamp.isUtc, isTrue);
});

test('clears active idle span when ended directly', () async {
final hub = fixture.getSut();
final idleSpan = hub.startIdleSpan(
Expand Down
13 changes: 10 additions & 3 deletions packages/flutter/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,18 @@ class _MyAppState extends State<MyApp> {
@override
void initState() {
super.initState();

doWork();
}

void doWork() async {
final rootDisplay = SentryFlutter.currentDisplay();
Future.delayed(const Duration(seconds: 3), () {
// Do some long running work...
rootDisplay?.reportFullyDisplayed();

await Sentry.startSpan('Custom span that runs during app start', (_) async {
await Future.delayed(const Duration(seconds: 1));
});

rootDisplay?.reportFullyDisplayed();
}

@override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ class GenericAppStartIntegration extends Integration<SentryFlutterOptions> {
void call(Hub hub, SentryFlutterOptions options) {
if (!options.isTracingEnabled()) return;

if (options.traceLifecycle == SentryTraceLifecycle.streaming) {
options.timeToDisplayTrackerV2.trackRootNavigation();
options.sdk.addIntegration(integrationName);
return;
}

final transactionContext = SentryTransactionContext(
'root /',
SentrySpanOperations.uiLoad,
Expand Down
122 changes: 122 additions & 0 deletions packages/flutter/lib/src/integrations/native_app_start_data.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// ignore_for_file: invalid_use_of_internal_member

import 'package:meta/meta.dart';

import '../../sentry_flutter.dart';
import '../native/native_app_start.dart';
import '../utils/internal_logger.dart';

/// We filter out App starts more than 60s
@internal
const maxAppStartMillis = 60000;

@internal
enum AppStartType { cold, warm }

@internal
class AppStartInfo {
AppStartInfo(
this.type, {
required this.start,
required this.end,
required this.pluginRegistration,
required this.sentrySetupStart,
required this.nativeSpanTimes,
});

final AppStartType type;
final DateTime start;
final DateTime end;
final List<TimeSpan> nativeSpanTimes;

final DateTime pluginRegistration;
final DateTime sentrySetupStart;

Duration get duration => end.difference(start);

SentryMeasurement toMeasurement() {
final duration = this.duration;
return type == AppStartType.cold
? SentryMeasurement.coldAppStart(duration)
: SentryMeasurement.warmAppStart(duration);
}

String get appStartTypeOperation => 'app.start.${type.name}';

String get appStartTypeDescription =>
type == AppStartType.cold ? 'Cold Start' : 'Warm Start';
final pluginRegistrationDescription = 'App start to plugin registration';
final sentrySetupDescription = 'Before Sentry Init Setup';
final firstFrameRenderDescription = 'First frame render';
}

@internal
class TimeSpan {
TimeSpan({required this.start, required this.end, required this.description});

final DateTime start;
final DateTime end;
final String description;
}

/// Parses and validates native app start data.
///
/// Returns `null` if validation fails (e.g. >60s duration, missing setup time).
@internal
AppStartInfo? parseNativeAppStart(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

pull out the common things we can to share logic with native app start handler v2 and v1

NativeAppStart nativeAppStart,
DateTime appStartEnd,
) {
final sentrySetupStartDateTime = SentryFlutter.sentrySetupStartTime;
if (sentrySetupStartDateTime == null) {
return null;
}

final appStartDateTime =
DateTime.fromMillisecondsSinceEpoch(nativeAppStart.appStartTime);
final pluginRegistrationDateTime = DateTime.fromMillisecondsSinceEpoch(
nativeAppStart.pluginRegistrationTime);

final duration = appStartEnd.difference(appStartDateTime);

// We filter out app start more than 60s.
// This could be due to many different reasons.
// If you do the manual init and init the SDK too late and it does not
// compute the app start end in the very first Screen.
// If the process starts but the App isn't in the foreground.
// If the system forked the process earlier to accelerate the app start.
// And some unknown reasons that could not be reproduced.
// We've seen app starts with hours, days and even months.
if (duration.inMilliseconds > maxAppStartMillis) {
return null;
}

List<TimeSpan> nativeSpanTimes = [];
for (final entry in nativeAppStart.nativeSpanTimes.entries) {
try {
final startTimestampMs = entry.value['startTimestampMsSinceEpoch'] as int;
final endTimestampMs = entry.value['stopTimestampMsSinceEpoch'] as int;
nativeSpanTimes.add(TimeSpan(
start: DateTime.fromMillisecondsSinceEpoch(startTimestampMs),
end: DateTime.fromMillisecondsSinceEpoch(endTimestampMs),
description: entry.key as String,
));
} catch (e) {
internalLogger.warning('Failed to parse native span times: $e');
continue;
}
}

// We want to sort because the native spans are not guaranteed to be in order.
// Performance wise this won't affect us since the native span amount is very low.
nativeSpanTimes.sort((a, b) => a.start.compareTo(b.start));

return AppStartInfo(
nativeAppStart.isColdStart ? AppStartType.cold : AppStartType.warm,
start: appStartDateTime,
end: appStartEnd,
pluginRegistration: pluginRegistrationDateTime,
sentrySetupStart: sentrySetupStartDateTime,
nativeSpanTimes: nativeSpanTimes,
);
}
Loading
Loading