Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Enhancements

- Add `sentry.replay_id` to flutter logs ([#3257](https://github.com/getsentry/sentry-dart/pull/3257))
### Features

- Add W3C `traceparent` header support ([#3246](https://github.com/getsentry/sentry-dart/pull/3246))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// ignore_for_file: invalid_use_of_internal_member

import 'package:sentry/sentry.dart';
import '../sentry_flutter_options.dart';

/// Integration that adds replay-related information to logs using lifecycle callbacks
class ReplayLogIntegration implements Integration<SentryFlutterOptions> {
static const String integrationName = 'ReplayLog';

SentryFlutterOptions? _options;
SdkLifecycleCallback<OnBeforeCaptureLog>? _addReplayInformation;

@override
Future<void> call(Hub hub, SentryFlutterOptions options) async {
_options = options;
_addReplayInformation = (OnBeforeCaptureLog event) {
final hasActiveReplay = hub.scope.replayId != null;

if (hasActiveReplay) {
event.log.attributes['sentry.replay_id'] = SentryLogAttribute.string(
hub.scope.replayId.toString(),
);
}

final isReplayEnabled = (options.replay.onErrorSampleRate ?? 0) > 0;
if (isReplayEnabled) {
event.log.attributes['sentry._internal.replay_is_buffering'] =
SentryLogAttribute.bool(
!hasActiveReplay,
);
}
};
options.lifecycleRegistry
.registerCallback<OnBeforeCaptureLog>(_addReplayInformation!);
options.sdk.addIntegration(integrationName);
}

@override
Future<void> close() async {
final options = _options;
final addReplayInformation = _addReplayInformation;

if (options != null && addReplayInformation != null) {
options.lifecycleRegistry
.removeCallback<OnBeforeCaptureLog>(addReplayInformation);
}

_options = null;
_addReplayInformation = null;
}
}
6 changes: 6 additions & 0 deletions packages/flutter/lib/src/sentry_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import 'integrations/flutter_framework_feature_flag_integration.dart';
import 'integrations/frames_tracking_integration.dart';
import 'integrations/integrations.dart';
import 'integrations/native_app_start_handler.dart';
import 'integrations/replay_log_integration.dart';
import 'integrations/screenshot_integration.dart';
import 'integrations/generic_app_start_integration.dart';
import 'integrations/thread_info_integration.dart';
Expand Down Expand Up @@ -230,6 +231,11 @@ mixin SentryFlutter {

integrations.add(DebugPrintIntegration());

// Only add ReplayLogIntegration on platforms that support replay
if (native != null && native.supportsReplay) {
integrations.add(ReplayLogIntegration());
}

if (!platform.isWeb) {
integrations.add(ThreadInfoIntegration());
}
Expand Down
192 changes: 192 additions & 0 deletions packages/flutter/test/integrations/replay_log_integration_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// ignore_for_file: invalid_use_of_internal_member

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:sentry_flutter/src/integrations/replay_log_integration.dart';

import '../mocks.mocks.dart';

void main() {
late Fixture fixture;

setUp(() {
fixture = Fixture();
});

group('ReplayLogIntegration', () {
test('adds replay_id attribute when replay is active', () async {
final integration = fixture.getSut();

fixture.options.replay.onErrorSampleRate = 0.5;
fixture.hub.scope.replayId = SentryId.fromId('test-replay-id');

await integration.call(fixture.hub, fixture.options);

final log = fixture.createTestLog();
await fixture.hub.captureLog(log);

expect(log.attributes['sentry.replay_id']?.value, 'testreplayid');
expect(log.attributes['sentry.replay_id']?.type, 'string');

expect(
log.attributes['sentry._internal.replay_is_buffering']?.value, false);
expect(log.attributes['sentry._internal.replay_is_buffering']?.type,
'boolean');
});

test('adds replay buffering flag when replay is enabled but not active',
() async {
final integration = fixture.getSut();
fixture.options.replay.onErrorSampleRate = 0.5;

await integration.call(fixture.hub, fixture.options);

final log = fixture.createTestLog();
await fixture.hub.captureLog(log);

expect(log.attributes.containsKey('sentry.replay_id'), false);

expect(
log.attributes['sentry._internal.replay_is_buffering']?.value, true);
expect(log.attributes['sentry._internal.replay_is_buffering']?.type,
'boolean');
});

test('does not add buffering flag when onErrorSampleRate is disabled',
() async {
final integration = fixture.getSut();
fixture.options.replay.onErrorSampleRate = 0.0;

await integration.call(fixture.hub, fixture.options);

final log = fixture.createTestLog();
await fixture.hub.captureLog(log);

expect(log.attributes.containsKey('sentry.replay_id'), false);
expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'),
false);
});

test(
'adds replay_id but not buffering flag when onErrorSampleRate is disabled',
() async {
final integration = fixture.getSut();
fixture.options.replay.onErrorSampleRate = 0.0;
fixture.hub.scope.replayId = SentryId.fromId('test-replay-id');

await integration.call(fixture.hub, fixture.options);

final log = fixture.createTestLog();
await fixture.hub.captureLog(log);

expect(log.attributes['sentry.replay_id']?.value, 'testreplayid');
expect(log.attributes['sentry.replay_id']?.type, 'string');

expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'),
false);
});

test('does not add buffering flag when onErrorSampleRate is null',
() async {
final integration = fixture.getSut();

fixture.options.replay.onErrorSampleRate = null;

await integration.call(fixture.hub, fixture.options);

final log = fixture.createTestLog();
await fixture.hub.captureLog(log);

expect(log.attributes.containsKey('sentry.replay_id'), false);
expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'),
false);
});

test('adds replay_id but not buffering flag when onErrorSampleRate is null',
() async {
final integration = fixture.getSut();
fixture.options.replay.onErrorSampleRate = null;
fixture.hub.scope.replayId = SentryId.fromId('test-replay-id');

await integration.call(fixture.hub, fixture.options);

final log = fixture.createTestLog();
await fixture.hub.captureLog(log);

expect(log.attributes['sentry.replay_id']?.value, 'testreplayid');
expect(log.attributes['sentry.replay_id']?.type, 'string');

expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'),
false);
});

test('registers integration name in SDK', () async {
final integration = fixture.getSut();

fixture.options.replay.onErrorSampleRate = 0.5;
fixture.hub.scope.replayId = SentryId.fromId('test-replay-id');

await integration.call(fixture.hub, fixture.options);

// Integration name is registered in SDK
expect(fixture.options.sdk.integrations.contains('ReplayLog'), true);
});

test('removes callback on close', () async {
final integration = fixture.getSut();

fixture.options.replay.onErrorSampleRate = 0.5;
fixture.hub.scope.replayId = SentryId.fromId('test-replay-id');

await integration.call(fixture.hub, fixture.options);
await integration.close();

final log = fixture.createTestLog();
await fixture.hub.captureLog(log);

expect(log.attributes.containsKey('sentry.replay_id'), false);
expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'),
false);
});

test('integration name is correct', () {
expect(ReplayLogIntegration.integrationName, 'ReplayLog');
});
});
}

class Fixture {
final options =
SentryFlutterOptions(dsn: 'https://[email protected]/1234567');
final hub = MockHub();

Fixture() {
options.enableLogs = true;
options.environment = 'test';
options.release = 'test-release';

final scope = Scope(options);
when(hub.options).thenReturn(options);
when(hub.scope).thenReturn(scope);
when(hub.captureLog(any)).thenAnswer((invocation) async {
final log = invocation.positionalArguments.first as SentryLog;
// Trigger the lifecycle callback
await options.lifecycleRegistry.dispatchCallback(OnBeforeCaptureLog(log));
});
}

SentryLog createTestLog() {
return SentryLog(
timestamp: DateTime.now(),
traceId: SentryId.newId(),
level: SentryLogLevel.info,
body: 'test log message',
attributes: <String, SentryLogAttribute>{},
);
}

ReplayLogIntegration getSut() {
return ReplayLogIntegration();
}
}
15 changes: 15 additions & 0 deletions packages/flutter/test/sentry_flutter_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:sentry_flutter/src/file_system_transport.dart';
import 'package:sentry_flutter/src/flutter_exception_type_identifier.dart';
import 'package:sentry_flutter/src/integrations/connectivity/connectivity_integration.dart';
import 'package:sentry_flutter/src/integrations/integrations.dart';
import 'package:sentry_flutter/src/integrations/replay_log_integration.dart';
import 'package:sentry_flutter/src/integrations/screenshot_integration.dart';
import 'package:sentry_flutter/src/integrations/generic_app_start_integration.dart';
import 'package:sentry_flutter/src/integrations/thread_info_integration.dart';
Expand Down Expand Up @@ -49,6 +50,11 @@ final nonWebIntegrations = [
ThreadInfoIntegration,
];

// These platforms support replay functionality
final replaySupportedIntegrations = [
ReplayLogIntegration,
];

// These should be added to Android
final androidIntegrations = [
LoadContextsIntegration,
Expand Down Expand Up @@ -106,6 +112,7 @@ void main() {
...androidIntegrations,
...platformAgnosticIntegrations,
...nonWebIntegrations,
...replaySupportedIntegrations,
ReplayIntegration,
],
shouldNotHaveIntegrations: [
Expand Down Expand Up @@ -164,6 +171,7 @@ void main() {
...iOsAndMacOsIntegrations,
...platformAgnosticIntegrations,
...nonWebIntegrations,
...replaySupportedIntegrations,
ReplayIntegration,
],
shouldNotHaveIntegrations: [
Expand Down Expand Up @@ -220,6 +228,7 @@ void main() {
], shouldNotHaveIntegrations: [
...androidIntegrations,
...nonWebIntegrations,
...replaySupportedIntegrations,
]);

testBefore(
Expand Down Expand Up @@ -270,6 +279,7 @@ void main() {
...androidIntegrations,
...iOsAndMacOsIntegrations,
...webIntegrations,
...replaySupportedIntegrations,
],
);

Expand Down Expand Up @@ -319,6 +329,7 @@ void main() {
...androidIntegrations,
...iOsAndMacOsIntegrations,
...webIntegrations,
...replaySupportedIntegrations,
],
);

Expand Down Expand Up @@ -369,6 +380,7 @@ void main() {
...androidIntegrations,
...iOsAndMacOsIntegrations,
...nonWebIntegrations,
...replaySupportedIntegrations,
],
);

Expand Down Expand Up @@ -440,6 +452,7 @@ void main() {
...androidIntegrations,
...iOsAndMacOsIntegrations,
...nonWebIntegrations,
...replaySupportedIntegrations,
],
);

Expand Down Expand Up @@ -485,6 +498,7 @@ void main() {
...androidIntegrations,
...iOsAndMacOsIntegrations,
...nonWebIntegrations,
...replaySupportedIntegrations,
],
);

Expand Down Expand Up @@ -530,6 +544,7 @@ void main() {
...androidIntegrations,
...iOsAndMacOsIntegrations,
...nonWebIntegrations,
...replaySupportedIntegrations,
],
);

Expand Down
2 changes: 1 addition & 1 deletion packages/flutter/test/sentry_flutter_util.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ void testConfiguration({
shouldNotHaveIntegrations = Set.of(shouldNotHaveIntegrations)
.difference(Set.of(shouldHaveIntegrations));
for (final type in shouldNotHaveIntegrations) {
expect(integrations, isNot(contains(type)));
expect(integrations.any((i) => i.runtimeType == type), false);
}

Integration? nativeIntegration;
Expand Down
Loading