Skip to content

Improve performance of frames tracking #2854

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Apr 15, 2025
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Improvements

- Improve performance of frames tracking ([#2854](https://github.com/getsentry/sentry-dart/pull/2854))

### Fixes

- `options.diagnosticLevel` not affecting logs ([#2856](https://github.com/getsentry/sentry-dart/pull/2856))
Expand Down
73 changes: 48 additions & 25 deletions flutter/lib/src/binding_wrapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,37 +73,50 @@
DateTime startTimestamp, DateTime endTimestamp);

mixin SentryWidgetsBindingMixin on WidgetsBinding {
DateTime? _startTimestamp;
FrameTimingCallback? _frameTimingCallback;
ClockProvider? _clock;

SentryOptions get _options => Sentry.currentHub.options;
FrameTimingCallback? _onDelayedFrame;
FrameTimingCallback? get onDelayedFrame => _onDelayedFrame;
Duration? _expectedFrameDuration;
Duration? get expectedFrameDuration => _expectedFrameDuration;
bool _isTrackingActive = false;
SentryOptions? _options;
SentryOptions? get options => _options;
final Stopwatch _stopwatch = Stopwatch();

@internal
void registerFramesTracking(
FrameTimingCallback callback, ClockProvider clock) {
_frameTimingCallback ??= callback;
_clock ??= clock;
void initializeFramesTracking(FrameTimingCallback onDelayedFrame,
SentryOptions options, Duration expectedFrameDuration) {
_onDelayedFrame ??= onDelayedFrame;
_options ??= options;
_expectedFrameDuration ??= expectedFrameDuration;
}

void resumeTrackingFrames() {
_isTrackingActive = true;
}

@visibleForTesting
bool isFramesTrackingInitialized() {
return _frameTimingCallback != null && _clock != null;
void pauseTrackingFrames() {
// Stopwatch could continue running if we pause tracking in between a frame
_stopwatch.stop();
_stopwatch.reset();
_isTrackingActive = false;
}

@internal
void removeFramesTracking() {
_frameTimingCallback = null;
_clock = null;
_onDelayedFrame = null;
_expectedFrameDuration = null;
_options = null;
}

@override
void handleBeginFrame(Duration? rawTimeStamp) {
try {
_startTimestamp = _clock?.call();
} catch (_) {
if (_options.automatedTestMode) {
rethrow;
if (_isTrackingActive) {
try {
_stopwatch.start();
} catch (_) {
if (_options?.automatedTestMode == true) {

Check warning on line 117 in flutter/lib/src/binding_wrapper.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/binding_wrapper.dart#L117

Added line #L117 was not covered by tests
rethrow;
}
}
}

Expand All @@ -114,15 +127,25 @@
void handleDrawFrame() {
super.handleDrawFrame();

if (!_isTrackingActive) {
return;
}
final expectedFrameDuration = _expectedFrameDuration;
final options = _options;
try {
final endTimestamp = _clock?.call();
if (_startTimestamp != null &&
endTimestamp != null &&
_startTimestamp!.isBefore(endTimestamp)) {
_frameTimingCallback?.call(_startTimestamp!, endTimestamp);
_stopwatch.stop();
if (options != null &&
expectedFrameDuration != null &&
_stopwatch.elapsedMilliseconds >
expectedFrameDuration.inMilliseconds) {
final endTimestamp = options.clock();
final startTimestamp = endTimestamp
.subtract(Duration(milliseconds: _stopwatch.elapsedMilliseconds));
_onDelayedFrame?.call(startTimestamp, endTimestamp);
}
_stopwatch.reset();
} catch (_) {
if (_options.automatedTestMode) {
if (_options?.automatedTestMode == true) {

Check warning on line 148 in flutter/lib/src/binding_wrapper.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/binding_wrapper.dart#L148

Added line #L148 was not covered by tests
rethrow;
}
}
Expand Down
47 changes: 16 additions & 31 deletions flutter/lib/src/frames_tracking/sentry_delayed_frames_tracker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import 'dart:math';

import 'package:meta/meta.dart';

import '../../sentry_flutter.dart';

/// This is just an upper limit, ensuring that the buffer does not grow
Expand Down Expand Up @@ -35,22 +36,13 @@ class SentryDelayedFramesTracker {
/// Since startFrame and endFrame is always called sequentially by Flutter we
/// don't need a SplayTree
final List<SentryFrameTiming> _delayedFrames = [];
@visibleForTesting
List<SentryFrameTiming> get delayedFrames => _delayedFrames.toList();
final SentryFlutterOptions _options;
final Duration _expectedFrameDuration;
DateTime? _oldestFrameEndTimestamp;
@visibleForTesting
DateTime? get oldestFrameEndTimestamp => _oldestFrameEndTimestamp;
DateTime? _oldestFrameEndTimestamp;
bool _isTrackingActive = false;

/// Resumes the collecting of frames.
void resume() {
_isTrackingActive = true;
}

/// Pauses the collecting of frames.
void pause() {
_isTrackingActive = false;
}

/// Retrieves the frames the intersect with the provided [startTimestamp] and [endTimestamp].
@visibleForTesting
Expand Down Expand Up @@ -80,27 +72,27 @@ class SentryDelayedFramesTracker {
}).toList(growable: false);
}

/// Records the start and end time of a delayed frame.
///
/// [startTimestamp] The time when the delayed frame rendering started.
/// [endTimestamp] The time when the delayed frame rendering ended.
@pragma('vm:prefer-inline')
void addFrame(DateTime startTimestamp, DateTime endTimestamp) {
if (!_isTrackingActive || !_options.enableFramesTracking) {
void addDelayedFrame(DateTime startTimestamp, DateTime endTimestamp) {
if (!_options.enableFramesTracking) {
return;
}
if (startTimestamp.isAfter(endTimestamp)) {
return;
}
if (_delayedFrames.length > maxDelayedFramesBuffer) {
// buffer is full, we stop collecting frames until all active spans have
// finished processing
pause();
_options.logger(SentryLevel.debug,
'Frame tracking buffer is full, stopping frame collection until all active spans have finished processing');
return;
}
final duration = endTimestamp.difference(startTimestamp);
if (duration > _expectedFrameDuration) {
final frameTiming = SentryFrameTiming(
startTimestamp: startTimestamp, endTimestamp: endTimestamp);
_delayedFrames.add(frameTiming);
_oldestFrameEndTimestamp ??= endTimestamp;
}
final frameTiming = SentryFrameTiming(
startTimestamp: startTimestamp, endTimestamp: endTimestamp);
_delayedFrames.add(frameTiming);
_oldestFrameEndTimestamp ??= endTimestamp;
}

void removeIrrelevantFrames(DateTime spanStartTimestamp) {
Expand Down Expand Up @@ -227,15 +219,8 @@ class SentryDelayedFramesTracker {
/// Clears the state of the tracker.
void clear() {
_delayedFrames.clear();
pause();
_oldestFrameEndTimestamp = null;
}

@visibleForTesting
List<SentryFrameTiming> get delayedFrames => _delayedFrames.toList();

@visibleForTesting
bool get isTrackingActive => _isTrackingActive;
}

/// Frame timing that represents an approximation of the frame's build duration.
Expand Down
14 changes: 12 additions & 2 deletions flutter/lib/src/frames_tracking/span_frame_metrics_collector.dart
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
// ignore_for_file: invalid_use_of_internal_member

import 'package:meta/meta.dart';

import '../../sentry_flutter.dart';
import 'sentry_delayed_frames_tracker.dart';

/// Collects frames from [SentryDelayedFramesTracker], calculates the metrics
/// and attaches them to spans.
@internal
class SpanFrameMetricsCollector implements PerformanceContinuousCollector {
SpanFrameMetricsCollector(this._options, this._frameTracker);
SpanFrameMetricsCollector(
this._options,
this._frameTracker, {
required void Function() resumeFrameTracking,
required void Function() pauseFrameTracking,
}) : _resumeFrameTracking = resumeFrameTracking,
_pauseFrameTracking = pauseFrameTracking;

final SentryFlutterOptions _options;
final SentryDelayedFramesTracker _frameTracker;
final void Function() _resumeFrameTracking;
final void Function() _pauseFrameTracking;

/// Stores the spans that are actively being tracked.
/// After the frames are calculated and stored in the span the span is removed from this list.
Expand All @@ -26,7 +35,7 @@ class SpanFrameMetricsCollector implements PerformanceContinuousCollector {
}

activeSpans.add(span);
_frameTracker.resume();
_resumeFrameTracking();
});
}

Expand Down Expand Up @@ -69,6 +78,7 @@ class SpanFrameMetricsCollector implements PerformanceContinuousCollector {

@override
void clear() {
_pauseFrameTracking();
_frameTracker.clear();
activeSpans.clear();
// we don't need to clear the expected frame duration as that realistically
Expand Down
13 changes: 9 additions & 4 deletions flutter/lib/src/integrations/frames_tracking_integration.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// ignore_for_file: invalid_use_of_internal_member

import 'dart:core';

import '../../sentry_flutter.dart';
import '../binding_wrapper.dart';
import '../frames_tracking/sentry_delayed_frames_tracker.dart';
Expand All @@ -9,6 +11,7 @@ import '../native/sentry_native_binding.dart';
class FramesTrackingIntegration implements Integration<SentryFlutterOptions> {
FramesTrackingIntegration(this._native);

static const integrationName = 'FramesTracking';
final SentryNativeBinding _native;
SentryFlutterOptions? _options;
PerformanceCollector? _collector;
Expand Down Expand Up @@ -45,13 +48,15 @@ class FramesTrackingIntegration implements Integration<SentryFlutterOptions> {
// Everything valid, we can initialize now
final framesTracker =
SentryDelayedFramesTracker(options, expectedFrameDuration);
widgetsBinding.registerFramesTracking(
framesTracker.addFrame, options.clock);
final collector = SpanFrameMetricsCollector(options, framesTracker);
widgetsBinding.initializeFramesTracking(
framesTracker.addDelayedFrame, options, expectedFrameDuration);
final collector = SpanFrameMetricsCollector(options, framesTracker,
resumeFrameTracking: () => widgetsBinding.resumeTrackingFrames(),
pauseFrameTracking: () => widgetsBinding.pauseTrackingFrames());
options.addPerformanceCollector(collector);
_collector = collector;

options.sdk.addIntegration('framesTrackingIntegration');
options.sdk.addIntegration(integrationName);
options.logger(SentryLevel.debug,
'$FramesTrackingIntegration successfully initialized with an expected frame duration of ${expectedFrameDuration.inMilliseconds}ms');
}
Expand Down
15 changes: 11 additions & 4 deletions flutter/test/frame_tracking/frames_tracking_integration_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,15 @@ void main() {
await integration.call(Hub(options), options);
}

bool isFramesTrackingInitialized(SentryWidgetsBindingMixin binding) {
return binding.options != null &&
binding.onDelayedFrame != null &&
binding.expectedFrameDuration != null;
}

void assertInitFailure() {
if (widgetsBinding != null) {
expect(widgetsBinding!.isFramesTrackingInitialized(), isFalse);
expect(isFramesTrackingInitialized(widgetsBinding!), isFalse);
}
expect(options.performanceCollectors, isEmpty);
}
Expand All @@ -74,18 +80,19 @@ void main() {
test('adds integration to SDK list', () async {
await fromWorkingState(options);

expect(options.sdk.integrations, contains('framesTrackingIntegration'));
expect(options.sdk.integrations,
contains(FramesTrackingIntegration.integrationName));
});

test('properly cleans up resources on close', () async {
await fromWorkingState(options);

expect(widgetsBinding!.isFramesTrackingInitialized(), isTrue);
expect(isFramesTrackingInitialized(widgetsBinding!), isTrue);
expect(options.performanceCollectors, isNotEmpty);

integration.close();

expect(widgetsBinding!.isFramesTrackingInitialized(), isFalse);
expect(isFramesTrackingInitialized(widgetsBinding!), isFalse);
expect(options.performanceCollectors, isEmpty);
});

Expand Down
Loading
Loading