From 234bb86ea0f6cdc6b2077567d423064b75ad94d9 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 17 Sep 2025 15:17:32 +0200 Subject: [PATCH 01/16] Add `sentry.replay_id` to flutter logs Relates to #3245 --- packages/dart/lib/src/sentry_client.dart | 7 ++++ packages/dart/test/sentry_client_test.dart | 46 ++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/packages/dart/lib/src/sentry_client.dart b/packages/dart/lib/src/sentry_client.dart index b8b1e7e5d0..76d9082951 100644 --- a/packages/dart/lib/src/sentry_client.dart +++ b/packages/dart/lib/src/sentry_client.dart @@ -532,6 +532,13 @@ class SentryClient { ); } + final replayId = scope?.replayId; + if (replayId != null) { + log.attributes['sentry.replay_id'] = SentryLogAttribute.string( + replayId.toString(), + ); + } + final user = scope?.user; final id = user?.id; final email = user?.email; diff --git a/packages/dart/test/sentry_client_test.dart b/packages/dart/test/sentry_client_test.dart index d0423fc2fd..5a8467535b 100644 --- a/packages/dart/test/sentry_client_test.dart +++ b/packages/dart/test/sentry_client_test.dart @@ -1885,6 +1885,52 @@ void main() { ); }); + test('should add replay id to attributes when available', () async { + fixture.options.enableLogs = true; + + final log = givenLog(); + final scope = Scope(fixture.options); + final replayId = SentryId.fromId('1988bb1b6f0d4c509e232f0cb9aaeaea'); + scope.replayId = replayId; + + final client = fixture.getSut(); + fixture.options.logBatcher = MockLogBatcher(); + + await client.captureLog(log, scope: scope); + + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls.length, 1); + final capturedLog = mockLogBatcher.addLogCalls.first; + + expect( + capturedLog.attributes['sentry.replay_id']?.value, + replayId.toString(), + ); + expect( + capturedLog.attributes['sentry.replay_id']?.type, + 'string', + ); + }); + + test('should not add replay id to attributes when not available', () async { + fixture.options.enableLogs = true; + + final log = givenLog(); + final scope = Scope(fixture.options); + // replayId is null by default + + final client = fixture.getSut(); + fixture.options.logBatcher = MockLogBatcher(); + + await client.captureLog(log, scope: scope); + + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls.length, 1); + final capturedLog = mockLogBatcher.addLogCalls.first; + + expect(capturedLog.attributes['sentry.replay_id'], isNull); + }); + test('should set trace id from propagation context', () async { fixture.options.enableLogs = true; From c36531650f3b36498ede68e7808bc126b44e2491 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 17 Sep 2025 15:19:40 +0200 Subject: [PATCH 02/16] add cl entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc05b148dc..1adb2f7f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Enhancements + +- Add `sentry.replay_id` to flutter logs ([#3257](https://github.com/getsentry/sentry-dart/pull/3257)) + ## 9.7.0-beta.5 ### Dependencies From f0030f0cc95b0b937922e459c9f69745ebeb6d38 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Mon, 29 Sep 2025 16:35:13 +0200 Subject: [PATCH 03/16] Add `sentry._internal.replay_is_buffering` attribute --- .../integrations/replay_log_integration.dart | 51 ++++++ packages/flutter/lib/src/sentry_flutter.dart | 6 + .../replay_log_integration_test.dart | 153 ++++++++++++++++++ .../flutter/test/sentry_flutter_test.dart | 15 ++ .../flutter/test/sentry_flutter_util.dart | 2 +- 5 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 packages/flutter/lib/src/integrations/replay_log_integration.dart create mode 100644 packages/flutter/test/integrations/replay_log_integration_test.dart diff --git a/packages/flutter/lib/src/integrations/replay_log_integration.dart b/packages/flutter/lib/src/integrations/replay_log_integration.dart new file mode 100644 index 0000000000..931f7f91e5 --- /dev/null +++ b/packages/flutter/lib/src/integrations/replay_log_integration.dart @@ -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 { + static const String integrationName = 'ReplayLog'; + + SentryFlutterOptions? _options; + SdkLifecycleCallback? _addReplayInformation; + + @override + Future call(Hub hub, SentryFlutterOptions options) async { + _options = options; + _addReplayInformation = (OnBeforeCaptureLog event) { + final isReplayEnabled = (options.replay.onErrorSampleRate ?? 0) > 0; + if (!isReplayEnabled) { + return; + } + final hasActiveReplay = hub.scope.replayId != null; + + if (hasActiveReplay) { + event.log.attributes['sentry.replay_id'] = SentryLogAttribute.string( + hub.scope.replayId.toString(), + ); + } + event.log.attributes['sentry._internal.replay_is_buffering'] = + SentryLogAttribute.bool( + !hasActiveReplay, + ); + }; + options.lifecycleRegistry + .registerCallback(_addReplayInformation!); + options.sdk.addIntegration(integrationName); + } + + @override + Future close() async { + final options = _options; + final addReplayInformation = _addReplayInformation; + + if (options != null && addReplayInformation != null) { + options.lifecycleRegistry + .removeCallback(addReplayInformation); + } + + _options = null; + _addReplayInformation = null; + } +} diff --git a/packages/flutter/lib/src/sentry_flutter.dart b/packages/flutter/lib/src/sentry_flutter.dart index 03291edf1a..c8f4d82dcf 100644 --- a/packages/flutter/lib/src/sentry_flutter.dart +++ b/packages/flutter/lib/src/sentry_flutter.dart @@ -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'; @@ -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()); } diff --git a/packages/flutter/test/integrations/replay_log_integration_test.dart b/packages/flutter/test/integrations/replay_log_integration_test.dart new file mode 100644 index 0000000000..3ffcc7084b --- /dev/null +++ b/packages/flutter/test/integrations/replay_log_integration_test.dart @@ -0,0 +1,153 @@ +// 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 attributes when replay 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('does not add attributes when replay sample rate 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('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://abc@def.ingest.sentry.io/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: {}, + ); + } + + ReplayLogIntegration getSut() { + return ReplayLogIntegration(); + } +} diff --git a/packages/flutter/test/sentry_flutter_test.dart b/packages/flutter/test/sentry_flutter_test.dart index 5da3e3d5cf..30810e0880 100644 --- a/packages/flutter/test/sentry_flutter_test.dart +++ b/packages/flutter/test/sentry_flutter_test.dart @@ -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'; @@ -49,6 +50,11 @@ final nonWebIntegrations = [ ThreadInfoIntegration, ]; +// These platforms support replay functionality +final replaySupportedIntegrations = [ + ReplayLogIntegration, +]; + // These should be added to Android final androidIntegrations = [ LoadContextsIntegration, @@ -106,6 +112,7 @@ void main() { ...androidIntegrations, ...platformAgnosticIntegrations, ...nonWebIntegrations, + ...replaySupportedIntegrations, ReplayIntegration, ], shouldNotHaveIntegrations: [ @@ -164,6 +171,7 @@ void main() { ...iOsAndMacOsIntegrations, ...platformAgnosticIntegrations, ...nonWebIntegrations, + ...replaySupportedIntegrations, ReplayIntegration, ], shouldNotHaveIntegrations: [ @@ -220,6 +228,7 @@ void main() { ], shouldNotHaveIntegrations: [ ...androidIntegrations, ...nonWebIntegrations, + ...replaySupportedIntegrations, ]); testBefore( @@ -270,6 +279,7 @@ void main() { ...androidIntegrations, ...iOsAndMacOsIntegrations, ...webIntegrations, + ...replaySupportedIntegrations, ], ); @@ -319,6 +329,7 @@ void main() { ...androidIntegrations, ...iOsAndMacOsIntegrations, ...webIntegrations, + ...replaySupportedIntegrations, ], ); @@ -369,6 +380,7 @@ void main() { ...androidIntegrations, ...iOsAndMacOsIntegrations, ...nonWebIntegrations, + ...replaySupportedIntegrations, ], ); @@ -440,6 +452,7 @@ void main() { ...androidIntegrations, ...iOsAndMacOsIntegrations, ...nonWebIntegrations, + ...replaySupportedIntegrations, ], ); @@ -485,6 +498,7 @@ void main() { ...androidIntegrations, ...iOsAndMacOsIntegrations, ...nonWebIntegrations, + ...replaySupportedIntegrations, ], ); @@ -530,6 +544,7 @@ void main() { ...androidIntegrations, ...iOsAndMacOsIntegrations, ...nonWebIntegrations, + ...replaySupportedIntegrations, ], ); diff --git a/packages/flutter/test/sentry_flutter_util.dart b/packages/flutter/test/sentry_flutter_util.dart index f951c95c5e..203815fe02 100644 --- a/packages/flutter/test/sentry_flutter_util.dart +++ b/packages/flutter/test/sentry_flutter_util.dart @@ -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; From 91562e220e8a2c4ae85d4982708e133bb66f9115 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Mon, 29 Sep 2025 16:36:59 +0200 Subject: [PATCH 04/16] remove in client --- packages/dart/lib/src/sentry_client.dart | 7 ------ packages/dart/test/sentry_client_test.dart | 27 ---------------------- 2 files changed, 34 deletions(-) diff --git a/packages/dart/lib/src/sentry_client.dart b/packages/dart/lib/src/sentry_client.dart index 76d9082951..b8b1e7e5d0 100644 --- a/packages/dart/lib/src/sentry_client.dart +++ b/packages/dart/lib/src/sentry_client.dart @@ -532,13 +532,6 @@ class SentryClient { ); } - final replayId = scope?.replayId; - if (replayId != null) { - log.attributes['sentry.replay_id'] = SentryLogAttribute.string( - replayId.toString(), - ); - } - final user = scope?.user; final id = user?.id; final email = user?.email; diff --git a/packages/dart/test/sentry_client_test.dart b/packages/dart/test/sentry_client_test.dart index 5a8467535b..240ae0181d 100644 --- a/packages/dart/test/sentry_client_test.dart +++ b/packages/dart/test/sentry_client_test.dart @@ -1885,33 +1885,6 @@ void main() { ); }); - test('should add replay id to attributes when available', () async { - fixture.options.enableLogs = true; - - final log = givenLog(); - final scope = Scope(fixture.options); - final replayId = SentryId.fromId('1988bb1b6f0d4c509e232f0cb9aaeaea'); - scope.replayId = replayId; - - final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); - - await client.captureLog(log, scope: scope); - - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls.length, 1); - final capturedLog = mockLogBatcher.addLogCalls.first; - - expect( - capturedLog.attributes['sentry.replay_id']?.value, - replayId.toString(), - ); - expect( - capturedLog.attributes['sentry.replay_id']?.type, - 'string', - ); - }); - test('should not add replay id to attributes when not available', () async { fixture.options.enableLogs = true; From 1fafd0c123493e8372fe75ddddae749aa3520a60 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Mon, 29 Sep 2025 16:38:41 +0200 Subject: [PATCH 05/16] remove old test --- packages/dart/test/sentry_client_test.dart | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/dart/test/sentry_client_test.dart b/packages/dart/test/sentry_client_test.dart index 240ae0181d..d0423fc2fd 100644 --- a/packages/dart/test/sentry_client_test.dart +++ b/packages/dart/test/sentry_client_test.dart @@ -1885,25 +1885,6 @@ void main() { ); }); - test('should not add replay id to attributes when not available', () async { - fixture.options.enableLogs = true; - - final log = givenLog(); - final scope = Scope(fixture.options); - // replayId is null by default - - final client = fixture.getSut(); - fixture.options.logBatcher = MockLogBatcher(); - - await client.captureLog(log, scope: scope); - - final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; - expect(mockLogBatcher.addLogCalls.length, 1); - final capturedLog = mockLogBatcher.addLogCalls.first; - - expect(capturedLog.attributes['sentry.replay_id'], isNull); - }); - test('should set trace id from propagation context', () async { fixture.options.enableLogs = true; From 0c6d307e4e7c32b2e532fdd0e7aa0a8d16b2b1a0 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Mon, 29 Sep 2025 16:47:27 +0200 Subject: [PATCH 06/16] update logic --- .../integrations/replay_log_integration.dart | 16 +++---- .../replay_log_integration_test.dart | 44 ++++++++++++++++++- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/flutter/lib/src/integrations/replay_log_integration.dart b/packages/flutter/lib/src/integrations/replay_log_integration.dart index 931f7f91e5..4ce179a085 100644 --- a/packages/flutter/lib/src/integrations/replay_log_integration.dart +++ b/packages/flutter/lib/src/integrations/replay_log_integration.dart @@ -14,10 +14,6 @@ class ReplayLogIntegration implements Integration { Future call(Hub hub, SentryFlutterOptions options) async { _options = options; _addReplayInformation = (OnBeforeCaptureLog event) { - final isReplayEnabled = (options.replay.onErrorSampleRate ?? 0) > 0; - if (!isReplayEnabled) { - return; - } final hasActiveReplay = hub.scope.replayId != null; if (hasActiveReplay) { @@ -25,10 +21,14 @@ class ReplayLogIntegration implements Integration { hub.scope.replayId.toString(), ); } - event.log.attributes['sentry._internal.replay_is_buffering'] = - SentryLogAttribute.bool( - !hasActiveReplay, - ); + + final isReplayEnabled = (options.replay.onErrorSampleRate ?? 0) > 0; + if (isReplayEnabled) { + event.log.attributes['sentry._internal.replay_is_buffering'] = + SentryLogAttribute.bool( + !hasActiveReplay, + ); + } }; options.lifecycleRegistry .registerCallback(_addReplayInformation!); diff --git a/packages/flutter/test/integrations/replay_log_integration_test.dart b/packages/flutter/test/integrations/replay_log_integration_test.dart index 3ffcc7084b..2bb699b892 100644 --- a/packages/flutter/test/integrations/replay_log_integration_test.dart +++ b/packages/flutter/test/integrations/replay_log_integration_test.dart @@ -53,7 +53,8 @@ void main() { 'boolean'); }); - test('does not add attributes when replay is disabled', () async { + test('does not add buffering flag when onErrorSampleRate is disabled', + () async { final integration = fixture.getSut(); fixture.options.replay.onErrorSampleRate = 0.0; @@ -67,7 +68,27 @@ void main() { false); }); - test('does not add attributes when replay sample rate is null', () async { + 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; @@ -82,6 +103,25 @@ void main() { 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(); From 02f86c325a14f92d77f8fc82ba473ebda1abca6b Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 30 Sep 2025 10:18:36 +0200 Subject: [PATCH 07/16] format --- .../flutter/test/integrations/replay_log_integration_test.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/flutter/test/integrations/replay_log_integration_test.dart b/packages/flutter/test/integrations/replay_log_integration_test.dart index 2bb699b892..da2b2fc6e5 100644 --- a/packages/flutter/test/integrations/replay_log_integration_test.dart +++ b/packages/flutter/test/integrations/replay_log_integration_test.dart @@ -103,8 +103,7 @@ void main() { false); }); - test( - 'adds replay_id but not buffering flag when onErrorSampleRate is null', + test('adds replay_id but not buffering flag when onErrorSampleRate is null', () async { final integration = fixture.getSut(); fixture.options.replay.onErrorSampleRate = null; From 9e888b681d25d93fd1d6c5c6566b04e1999e8723 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 7 Oct 2025 12:02:25 +0200 Subject: [PATCH 08/16] update replay id handling from ios/android native --- .../flutter/SentryFlutterReplayRecorder.kt | 7 +++++ .../SentryFlutterReplayScreenshotProvider.m | 4 +-- .../integrations/replay_log_integration.dart | 23 +++++++++------- .../lib/src/native/c/sentry_native.dart | 3 +++ .../src/native/cocoa/sentry_native_cocoa.dart | 22 +++++++++++---- .../src/native/java/sentry_native_java.dart | 18 ++++++++++--- .../lib/src/native/sentry_native_binding.dart | 2 ++ .../lib/src/native/sentry_native_channel.dart | 5 +++- packages/flutter/lib/src/sentry_flutter.dart | 2 +- packages/flutter/lib/src/web/sentry_web.dart | 3 +++ .../replay_log_integration_test.dart | 27 +++++++++++++------ .../test/replay/replay_native_test.dart | 24 +++++++++++++---- 12 files changed, 105 insertions(+), 35 deletions(-) diff --git a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt index a7897c002b..415732166e 100644 --- a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt +++ b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt @@ -4,6 +4,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import io.flutter.plugin.common.MethodChannel +import io.sentry.Sentry import io.sentry.android.replay.Recorder import io.sentry.android.replay.ReplayIntegration import io.sentry.android.replay.ScreenshotRecorderConfig @@ -15,9 +16,15 @@ internal class SentryFlutterReplayRecorder( override fun start() { Handler(Looper.getMainLooper()).post { try { + var scopeReplayId: String? = null + Sentry.configureScope { scope -> + scopeReplayId = scope.replayId?.toString() + } + channel.invokeMethod( "ReplayRecorder.start", mapOf( + "scope.replayId" to scopeReplayId, "replayId" to integration.getReplayId().toString(), ), ) diff --git a/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterReplayScreenshotProvider.m b/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterReplayScreenshotProvider.m index d8910fd98b..991816416f 100644 --- a/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterReplayScreenshotProvider.m +++ b/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterReplayScreenshotProvider.m @@ -24,10 +24,10 @@ - (void)imageWithView:(UIView *_Nonnull)view onComplete:(void (^_Nonnull)(UIImage *_Nonnull))onComplete { // Replay ID may be null if session replay is disabled. // Replay is still captured for on-error replays. - NSString *replayId = [PrivateSentrySDKOnly getReplayId]; + NSString *scopeReplayId = [PrivateSentrySDKOnly getReplayId]; [self->channel invokeMethod:@"captureReplayScreenshot" - arguments:@{@"replayId" : replayId ? replayId : [NSNull null]} + arguments:@{@"scope.replayId" : scopeReplayId ? scopeReplayId : [NSNull null]} result:^(id value) { if (value == nil) { NSLog(@"SentryFlutterReplayScreenshotProvider received null " diff --git a/packages/flutter/lib/src/integrations/replay_log_integration.dart b/packages/flutter/lib/src/integrations/replay_log_integration.dart index 4ce179a085..f668c39909 100644 --- a/packages/flutter/lib/src/integrations/replay_log_integration.dart +++ b/packages/flutter/lib/src/integrations/replay_log_integration.dart @@ -2,11 +2,15 @@ import 'package:sentry/sentry.dart'; import '../sentry_flutter_options.dart'; +import '../native/sentry_native_binding.dart'; /// Integration that adds replay-related information to logs using lifecycle callbacks class ReplayLogIntegration implements Integration { static const String integrationName = 'ReplayLog'; + final SentryNativeBinding? _native; + ReplayLogIntegration(this._native); + SentryFlutterOptions? _options; SdkLifecycleCallback? _addReplayInformation; @@ -14,20 +18,19 @@ class ReplayLogIntegration implements Integration { Future call(Hub hub, SentryFlutterOptions options) async { _options = options; _addReplayInformation = (OnBeforeCaptureLog event) { - final hasActiveReplay = hub.scope.replayId != null; + final scopeReplayId = hub.scope.replayId; + final replayId = _native?.replayId; - if (hasActiveReplay) { + if (scopeReplayId != null) { event.log.attributes['sentry.replay_id'] = SentryLogAttribute.string( - hub.scope.replayId.toString(), + scopeReplayId.toString(), ); - } - - final isReplayEnabled = (options.replay.onErrorSampleRate ?? 0) > 0; - if (isReplayEnabled) { - event.log.attributes['sentry._internal.replay_is_buffering'] = - SentryLogAttribute.bool( - !hasActiveReplay, + } else if (replayId != null) { + event.log.attributes['sentry.replay_id'] = SentryLogAttribute.string( + replayId.toString(), ); + event.log.attributes['sentry._internal.replay_is_buffering'] = + SentryLogAttribute.bool(true); } }; options.lifecycleRegistry diff --git a/packages/flutter/lib/src/native/c/sentry_native.dart b/packages/flutter/lib/src/native/c/sentry_native.dart index 793d103a23..85c0b602c6 100644 --- a/packages/flutter/lib/src/native/c/sentry_native.dart +++ b/packages/flutter/lib/src/native/c/sentry_native.dart @@ -276,6 +276,9 @@ class SentryNative with SentryNativeSafeInvoker implements SentryNativeBinding { @override bool get supportsReplay => false; + @override + SentryId? get replayId => null; + @override FutureOr setReplayConfig(ReplayConfig config) { _logNotSupported('replay config'); diff --git a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart index 7145f39f71..138bbfe0f9 100644 --- a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart +++ b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart @@ -20,6 +20,9 @@ class SentryNativeCocoa extends SentryNativeChannel { @override bool get supportsReplay => options.platform.isIOS; + @override + SentryId? get replayId => _replayId; + @override Future init(Hub hub) async { // We only need these when replay is enabled (session or error capture) @@ -30,15 +33,17 @@ class SentryNativeCocoa extends SentryNativeChannel { case 'captureReplayScreenshot': _replayRecorder ??= CocoaReplayRecorder(options); - final replayId = call.arguments['replayId'] == null + final scopeReplayId = call.arguments['scope.replayId'] == null ? null - : SentryId.fromId(call.arguments['replayId'] as String); + : SentryId.fromId(call.arguments['scope.replayId'] as String); + + // TODO: We need the replayId from cocoa integration, so we can determine if we are in buffer mode. - if (_replayId != replayId) { - _replayId = replayId; + if (_replayId != scopeReplayId) { + _replayId = scopeReplayId; hub.configureScope((s) { // ignore: invalid_use_of_internal_member - s.replayId = replayId; + s.replayId = scopeReplayId; }); } @@ -52,6 +57,13 @@ class SentryNativeCocoa extends SentryNativeChannel { return super.init(hub); } + @override + FutureOr captureReplay() async { + final replayId = await super.captureReplay(); + _replayId = replayId; + return replayId; + } + @override FutureOr captureEnvelope( Uint8List envelopeData, bool containsUnhandledException) { diff --git a/packages/flutter/lib/src/native/java/sentry_native_java.dart b/packages/flutter/lib/src/native/java/sentry_native_java.dart index 0c57f79179..2e6f0cd17f 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -19,6 +19,10 @@ class SentryNativeJava extends SentryNativeChannel { @override bool get supportsReplay => true; + @override + SentryId? get replayId => _replayId; + SentryId? _replayId; + @override Future init(Hub hub) async { // We only need these when replay is enabled (session or error capture) @@ -27,14 +31,15 @@ class SentryNativeJava extends SentryNativeChannel { channel.setMethodCallHandler((call) async { switch (call.method) { case 'ReplayRecorder.start': - final replayId = - SentryId.fromId(call.arguments['replayId'] as String); + final scopeReplayId = + SentryId.fromId(call.arguments['scope.replayId'] as String); + _replayId = SentryId.fromId(call.arguments['replayId'] as String); _replayRecorder = AndroidReplayRecorder.factory(options); await _replayRecorder!.start(); hub.configureScope((s) { // ignore: invalid_use_of_internal_member - s.replayId = replayId; + s.replayId = scopeReplayId; }); break; case 'ReplayRecorder.onConfigurationChanged': @@ -74,6 +79,13 @@ class SentryNativeJava extends SentryNativeChannel { return super.init(hub); } + @override + FutureOr captureReplay() async { + final replayId = await super.captureReplay(); + _replayId = replayId; + return replayId; + } + @override FutureOr captureEnvelope( Uint8List envelopeData, bool containsUnhandledException) { diff --git a/packages/flutter/lib/src/native/sentry_native_binding.dart b/packages/flutter/lib/src/native/sentry_native_binding.dart index db9d17916c..bf45423d25 100644 --- a/packages/flutter/lib/src/native/sentry_native_binding.dart +++ b/packages/flutter/lib/src/native/sentry_native_binding.dart @@ -64,6 +64,8 @@ abstract class SentryNativeBinding { bool get supportsReplay; + SentryId? get replayId; + FutureOr setReplayConfig(ReplayConfig config); FutureOr captureReplay(); diff --git a/packages/flutter/lib/src/native/sentry_native_channel.dart b/packages/flutter/lib/src/native/sentry_native_channel.dart index 20d72dd317..10e96c08c9 100644 --- a/packages/flutter/lib/src/native/sentry_native_channel.dart +++ b/packages/flutter/lib/src/native/sentry_native_channel.dart @@ -240,6 +240,9 @@ class SentryNativeChannel @override bool get supportsReplay => false; + @override + SentryId? get replayId => null; + @override FutureOr setReplayConfig(ReplayConfig config) => channel.invokeMethod('setReplayConfig', { @@ -251,7 +254,7 @@ class SentryNativeChannel }); @override - Future captureReplay() => channel + FutureOr captureReplay() => channel .invokeMethod('captureReplay') .then((value) => SentryId.fromId(value as String)); diff --git a/packages/flutter/lib/src/sentry_flutter.dart b/packages/flutter/lib/src/sentry_flutter.dart index c8f4d82dcf..d110b43e41 100644 --- a/packages/flutter/lib/src/sentry_flutter.dart +++ b/packages/flutter/lib/src/sentry_flutter.dart @@ -233,7 +233,7 @@ mixin SentryFlutter { // Only add ReplayLogIntegration on platforms that support replay if (native != null && native.supportsReplay) { - integrations.add(ReplayLogIntegration()); + integrations.add(ReplayLogIntegration(native)); } if (!platform.isWeb) { diff --git a/packages/flutter/lib/src/web/sentry_web.dart b/packages/flutter/lib/src/web/sentry_web.dart index 5a87bdc263..05fe22e09b 100644 --- a/packages/flutter/lib/src/web/sentry_web.dart +++ b/packages/flutter/lib/src/web/sentry_web.dart @@ -272,6 +272,9 @@ class SentryWeb with SentryNativeSafeInvoker implements SentryNativeBinding { @override bool get supportsReplay => false; + @override + SentryId? get replayId => null; + @override SentryFlutterOptions get options => _options; } diff --git a/packages/flutter/test/integrations/replay_log_integration_test.dart b/packages/flutter/test/integrations/replay_log_integration_test.dart index da2b2fc6e5..0800f676f3 100644 --- a/packages/flutter/test/integrations/replay_log_integration_test.dart +++ b/packages/flutter/test/integrations/replay_log_integration_test.dart @@ -15,7 +15,8 @@ void main() { }); group('ReplayLogIntegration', () { - test('adds replay_id attribute when replay is active', () async { + test('adds replay_id attribute when replay is active (scope replayId set)', + () async { final integration = fixture.getSut(); fixture.options.replay.onErrorSampleRate = 0.5; @@ -29,23 +30,29 @@ void main() { 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'); + // When scope replayId is set, no buffering flag should be added + expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), + false); }); - test('adds replay buffering flag when replay is enabled but not active', + test( + 'adds replay_id and buffering flag when replay is in buffer mode (native replayId set)', () async { final integration = fixture.getSut(); fixture.options.replay.onErrorSampleRate = 0.5; + // Mock native replayId to simulate buffering mode (Android) + final nativeReplayId = SentryId.fromId('native-replay-id'); + when(fixture.nativeBinding.replayId).thenReturn(nativeReplayId); + await integration.call(fixture.hub, fixture.options); final log = fixture.createTestLog(); await fixture.hub.captureLog(log); - expect(log.attributes.containsKey('sentry.replay_id'), false); + // In buffering mode, both replay_id and buffering flag should be present + expect(log.attributes['sentry.replay_id']?.value, 'nativereplayid'); + expect(log.attributes['sentry.replay_id']?.type, 'string'); expect( log.attributes['sentry._internal.replay_is_buffering']?.value, true); @@ -160,6 +167,7 @@ class Fixture { final options = SentryFlutterOptions(dsn: 'https://abc@def.ingest.sentry.io/1234567'); final hub = MockHub(); + final nativeBinding = MockSentryNativeBinding(); Fixture() { options.enableLogs = true; @@ -174,6 +182,9 @@ class Fixture { // Trigger the lifecycle callback await options.lifecycleRegistry.dispatchCallback(OnBeforeCaptureLog(log)); }); + + // Default: no native replayId + when(nativeBinding.replayId).thenReturn(null); } SentryLog createTestLog() { @@ -187,6 +198,6 @@ class Fixture { } ReplayLogIntegration getSut() { - return ReplayLogIntegration(); + return ReplayLogIntegration(nativeBinding); } } diff --git a/packages/flutter/test/replay/replay_native_test.dart b/packages/flutter/test/replay/replay_native_test.dart index 0641f95685..917c606fad 100644 --- a/packages/flutter/test/replay/replay_native_test.dart +++ b/packages/flutter/test/replay/replay_native_test.dart @@ -77,7 +77,16 @@ void main() { verifyNever(hub.configureScope(any)); when(hub.configureScope(captureAny)).thenReturn(null); - final replayConfig = {'replayId': '123'}; + // Android expects both 'scope.replayId' and 'replayId' + // iOS expects 'scope.replayId' + final replayConfig = mockPlatform.isAndroid + ? { + 'scope.replayId': '123', + 'replayId': '456', // internal replay ID for buffering + } + : { + 'scope.replayId': '123', + }; // emulate the native platform invoking the method final future = native.invokeFromNative( @@ -94,7 +103,7 @@ void main() { final scope = Scope(options); expect(scope.replayId, isNull); await closure(scope); - expect(scope.replayId.toString(), replayConfig['replayId']); + expect(scope.replayId.toString(), '123'); if (mockPlatform.isAndroid) { await native.invokeFromNative('ReplayRecorder.stop'); @@ -138,7 +147,10 @@ void main() { await tester.pumpAndWaitUntil(future, requiredToComplete: wait); } - final Map replayConfig = {'replayId': '123'}; + final Map replayConfig = { + 'scope.replayId': '123', + 'replayId': '456', + }; final configuration = { 'width': 800, 'height': 600, @@ -177,7 +189,9 @@ void main() { await nextFrame(wait: false); expect(mockAndroidRecorder.captured.length, equals(count)); } else if (mockPlatform.isIOS) { - final Map replayConfig = {'replayId': '123'}; + final Map replayConfig = { + 'scope.replayId': '123' + }; Future captureAndVerify() async { final future = native.invokeFromNative( @@ -196,7 +210,7 @@ void main() { // Check everything works if session-replay rate is 0, // which causes replayId to be 0 as well. - replayConfig['replayId'] = null; + replayConfig['scope.replayId'] = null; await captureAndVerify(); } else { fail('unsupported platform'); From b892b5574ac809d89111ce4b77ebe7b01b19bb9a Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 8 Oct 2025 10:17:29 +0200 Subject: [PATCH 09/16] remove newline --- .../main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt index 415732166e..1eac3cf42f 100644 --- a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt +++ b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt @@ -20,7 +20,6 @@ internal class SentryFlutterReplayRecorder( Sentry.configureScope { scope -> scopeReplayId = scope.replayId?.toString() } - channel.invokeMethod( "ReplayRecorder.start", mapOf( From 8b1e81e41378dc4376e9686f30621edc9a3c9762 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 8 Oct 2025 10:18:17 +0200 Subject: [PATCH 10/16] fix cl --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d23827a967..00c0130f39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,6 @@ ## 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)) @@ -13,6 +10,7 @@ ### Enhancements - Prefix firebase remote config feature flags with `firebase:` ([#3258](https://github.com/getsentry/sentry-dart/pull/3258)) +- Add `sentry.replay_id` to flutter logs ([#3257](https://github.com/getsentry/sentry-dart/pull/3257)) ### Fixes From 506f5cbf240a736010953327033bbdaa4bff6001 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 8 Oct 2025 10:41:11 +0200 Subject: [PATCH 11/16] additionally check options --- .../integrations/replay_log_integration.dart | 10 +- .../replay_log_integration_test.dart | 151 ++++++++++++++---- 2 files changed, 130 insertions(+), 31 deletions(-) diff --git a/packages/flutter/lib/src/integrations/replay_log_integration.dart b/packages/flutter/lib/src/integrations/replay_log_integration.dart index f668c39909..026ea4b388 100644 --- a/packages/flutter/lib/src/integrations/replay_log_integration.dart +++ b/packages/flutter/lib/src/integrations/replay_log_integration.dart @@ -16,16 +16,22 @@ class ReplayLogIntegration implements Integration { @override Future call(Hub hub, SentryFlutterOptions options) async { + if (!options.replay.isEnabled) { + return; + } + final sessionSampleRate = options.replay.sessionSampleRate ?? 0; + final onErrorSampleRate = options.replay.onErrorSampleRate ?? 0; + _options = options; _addReplayInformation = (OnBeforeCaptureLog event) { final scopeReplayId = hub.scope.replayId; final replayId = _native?.replayId; - if (scopeReplayId != null) { + if (sessionSampleRate > 0 && scopeReplayId != null) { event.log.attributes['sentry.replay_id'] = SentryLogAttribute.string( scopeReplayId.toString(), ); - } else if (replayId != null) { + } else if (onErrorSampleRate > 0 && replayId != null) { event.log.attributes['sentry.replay_id'] = SentryLogAttribute.string( replayId.toString(), ); diff --git a/packages/flutter/test/integrations/replay_log_integration_test.dart b/packages/flutter/test/integrations/replay_log_integration_test.dart index 0800f676f3..163bb7a678 100644 --- a/packages/flutter/test/integrations/replay_log_integration_test.dart +++ b/packages/flutter/test/integrations/replay_log_integration_test.dart @@ -15,11 +15,23 @@ void main() { }); group('ReplayLogIntegration', () { - test('adds replay_id attribute when replay is active (scope replayId set)', + test('does not register when replay is disabled', () async { + final integration = fixture.getSut(); + fixture.options.replay.sessionSampleRate = 0.0; + fixture.options.replay.onErrorSampleRate = 0.0; + + await integration.call(fixture.hub, fixture.options); + + // Integration should not be registered when replay is disabled + expect(fixture.options.sdk.integrations.contains('ReplayLog'), false); + }); + + test( + 'adds replay_id attribute when sessionSampleRate > 0 and scope replayId is set', () async { final integration = fixture.getSut(); - fixture.options.replay.onErrorSampleRate = 0.5; + fixture.options.replay.sessionSampleRate = 0.5; fixture.hub.scope.replayId = SentryId.fromId('test-replay-id'); await integration.call(fixture.hub, fixture.options); @@ -30,7 +42,72 @@ void main() { expect(log.attributes['sentry.replay_id']?.value, 'testreplayid'); expect(log.attributes['sentry.replay_id']?.type, 'string'); - // When scope replayId is set, no buffering flag should be added + // When scope replayId is set via session sample rate, no buffering flag should be added + expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), + false); + }); + + test( + 'does not add replay_id when sessionSampleRate is 0 even if scope replayId is set', + () async { + final integration = fixture.getSut(); + + fixture.options.replay.sessionSampleRate = 0.0; + fixture.options.replay.onErrorSampleRate = 0.5; // Needed to enable replay + 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.containsKey('sentry.replay_id'), false); + expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), + false); + }); + + test( + 'does not add replay_id when sessionSampleRate is null even if scope replayId is set', + () async { + final integration = fixture.getSut(); + + fixture.options.replay.sessionSampleRate = null; + fixture.options.replay.onErrorSampleRate = 0.5; // Needed to enable replay + 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.containsKey('sentry.replay_id'), false); + expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), + false); + }); + + test( + 'scope replayId takes precedence over native replayId when sessionSampleRate > 0', + () async { + final integration = fixture.getSut(); + + fixture.options.replay.sessionSampleRate = 0.5; + fixture.options.replay.onErrorSampleRate = 0.5; + fixture.hub.scope.replayId = SentryId.fromId('scope-replay-id'); + + // Mock native replayId to simulate buffering mode + final nativeReplayId = SentryId.fromId('native-replay-id'); + when(fixture.nativeBinding.replayId).thenReturn(nativeReplayId); + + await integration.call(fixture.hub, fixture.options); + + final log = fixture.createTestLog(); + await fixture.hub.captureLog(log); + + // Should use scope replayId, not native replayId + expect(log.attributes['sentry.replay_id']?.value, 'scopereplayid'); + expect(log.attributes['sentry.replay_id']?.type, 'string'); + + // Should NOT add buffering flag when scope replayId is used expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), false); }); @@ -60,79 +137,95 @@ void main() { 'boolean'); }); - test('does not add buffering flag when onErrorSampleRate is disabled', + test( + 'does not add anything when onErrorSampleRate is 0 and no scope replayId', () async { final integration = fixture.getSut(); + fixture.options.replay.sessionSampleRate = 0.5; // Needed to enable replay fixture.options.replay.onErrorSampleRate = 0.0; + // Mock native replayId to simulate buffering mode + final nativeReplayId = SentryId.fromId('native-replay-id'); + when(fixture.nativeBinding.replayId).thenReturn(nativeReplayId); + await integration.call(fixture.hub, fixture.options); final log = fixture.createTestLog(); await fixture.hub.captureLog(log); + // When onErrorSampleRate is 0, native replayId should be ignored 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', + 'does not add anything when onErrorSampleRate is null and no scope replayId', () async { final integration = fixture.getSut(); - fixture.options.replay.onErrorSampleRate = 0.0; - fixture.hub.scope.replayId = SentryId.fromId('test-replay-id'); + fixture.options.replay.sessionSampleRate = 0.5; // Needed to enable replay + fixture.options.replay.onErrorSampleRate = null; + + // Mock native replayId to simulate buffering mode + final nativeReplayId = SentryId.fromId('native-replay-id'); + when(fixture.nativeBinding.replayId).thenReturn(nativeReplayId); 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'); - + // When onErrorSampleRate is null, native replayId should be ignored + expect(log.attributes.containsKey('sentry.replay_id'), false); expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), false); }); - test('does not add buffering flag when onErrorSampleRate is null', + test( + 'does not add native replayId when scope replayId is not set and sessionSampleRate is 0', () async { final integration = fixture.getSut(); + fixture.options.replay.sessionSampleRate = 0.0; + fixture.options.replay.onErrorSampleRate = 0.5; - fixture.options.replay.onErrorSampleRate = null; + // Mock native replayId to simulate buffering mode + final nativeReplayId = SentryId.fromId('native-replay-id'); + when(fixture.nativeBinding.replayId).thenReturn(nativeReplayId); 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); + // When scope replayId is not set but onErrorSampleRate > 0, should use native replayId + expect(log.attributes['sentry.replay_id']?.value, 'nativereplayid'); + expect(log.attributes['sentry.replay_id']?.type, 'string'); + expect( + log.attributes['sentry._internal.replay_is_buffering']?.value, true); + expect(log.attributes['sentry._internal.replay_is_buffering']?.type, + 'boolean'); }); - test('adds replay_id but not buffering flag when onErrorSampleRate is null', - () async { + test('registers integration name in SDK with sessionSampleRate', () async { final integration = fixture.getSut(); - fixture.options.replay.onErrorSampleRate = null; + + fixture.options.replay.sessionSampleRate = 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.containsKey('sentry._internal.replay_is_buffering'), - false); + // Integration name is registered in SDK + expect(fixture.options.sdk.integrations.contains('ReplayLog'), true); }); - test('registers integration name in SDK', () async { + test('registers integration name in SDK with onErrorSampleRate', () async { final integration = fixture.getSut(); fixture.options.replay.onErrorSampleRate = 0.5; - fixture.hub.scope.replayId = SentryId.fromId('test-replay-id'); + + // Mock native replayId + final nativeReplayId = SentryId.fromId('native-replay-id'); + when(fixture.nativeBinding.replayId).thenReturn(nativeReplayId); await integration.call(fixture.hub, fixture.options); @@ -143,7 +236,7 @@ void main() { test('removes callback on close', () async { final integration = fixture.getSut(); - fixture.options.replay.onErrorSampleRate = 0.5; + fixture.options.replay.sessionSampleRate = 0.5; fixture.hub.scope.replayId = SentryId.fromId('test-replay-id'); await integration.call(fixture.hub, fixture.options); From 744d429f4cd154c0d547d6d8ea3f20422f775872 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 8 Oct 2025 10:45:15 +0200 Subject: [PATCH 12/16] check args --- .../lib/src/native/java/sentry_native_java.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/flutter/lib/src/native/java/sentry_native_java.dart b/packages/flutter/lib/src/native/java/sentry_native_java.dart index 2e6f0cd17f..3d26f0abe4 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -31,9 +31,15 @@ class SentryNativeJava extends SentryNativeChannel { channel.setMethodCallHandler((call) async { switch (call.method) { case 'ReplayRecorder.start': - final scopeReplayId = - SentryId.fromId(call.arguments['scope.replayId'] as String); - _replayId = SentryId.fromId(call.arguments['replayId'] as String); + final scopeReplayIdArg = call.arguments['scope.replayId']; + final replayIdArg = call.arguments['replayId']; + + final scopeReplayId = scopeReplayIdArg != null + ? SentryId.fromId(scopeReplayIdArg as String) + : null; + _replayId = replayIdArg != null + ? SentryId.fromId(replayIdArg as String) + : null; _replayRecorder = AndroidReplayRecorder.factory(options); await _replayRecorder!.start(); From a279e8db9972024cabc8cb8f4fd3b3e5bd014449 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 15 Oct 2025 11:54:56 +0200 Subject: [PATCH 13/16] Change how native side reports the replay id --- .../flutter/SentryFlutterReplayRecorder.kt | 10 +-- .../SentryFlutterReplayScreenshotProvider.m | 10 ++- .../integrations/replay_log_integration.dart | 9 ++- .../src/native/cocoa/sentry_native_cocoa.dart | 17 +++-- .../src/native/java/sentry_native_java.dart | 12 ++-- .../replay_log_integration_test.dart | 67 ++++++++++--------- .../test/replay/replay_native_test.dart | 16 ++--- 7 files changed, 79 insertions(+), 62 deletions(-) diff --git a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt index 1eac3cf42f..a665fcbfe0 100644 --- a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt +++ b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt @@ -16,15 +16,17 @@ internal class SentryFlutterReplayRecorder( override fun start() { Handler(Looper.getMainLooper()).post { try { - var scopeReplayId: String? = null + val replayId = integration.getReplayId().toString() + var replayIsBuffering = false Sentry.configureScope { scope -> - scopeReplayId = scope.replayId?.toString() + // Buffering mode: we have a replay ID but it's not set on scope yet + replayIsBuffering = replayId != null && scope.replayId == null } channel.invokeMethod( "ReplayRecorder.start", mapOf( - "scope.replayId" to scopeReplayId, - "replayId" to integration.getReplayId().toString(), + "replayId" to replayId, + "replayIsBuffering" to replayIsBuffering, ), ) } catch (ignored: Exception) { diff --git a/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterReplayScreenshotProvider.m b/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterReplayScreenshotProvider.m index 991816416f..e22f4f9df6 100644 --- a/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterReplayScreenshotProvider.m +++ b/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterReplayScreenshotProvider.m @@ -24,10 +24,16 @@ - (void)imageWithView:(UIView *_Nonnull)view onComplete:(void (^_Nonnull)(UIImage *_Nonnull))onComplete { // Replay ID may be null if session replay is disabled. // Replay is still captured for on-error replays. - NSString *scopeReplayId = [PrivateSentrySDKOnly getReplayId]; + NSString *replayId = [PrivateSentrySDKOnly getReplayId]; + // On iOS, we only have access to scope's replay ID, so we cannot detect buffer mode + // If replay ID exists, it's always in active session mode (not buffering) + BOOL replayIsBuffering = NO; [self->channel invokeMethod:@"captureReplayScreenshot" - arguments:@{@"scope.replayId" : scopeReplayId ? scopeReplayId : [NSNull null]} + arguments:@{ + @"replayId" : replayId ? replayId : [NSNull null], + @"replayIsBuffering" : @(replayIsBuffering) + } result:^(id value) { if (value == nil) { NSLog(@"SentryFlutterReplayScreenshotProvider received null " diff --git a/packages/flutter/lib/src/integrations/replay_log_integration.dart b/packages/flutter/lib/src/integrations/replay_log_integration.dart index 026ea4b388..99266a5328 100644 --- a/packages/flutter/lib/src/integrations/replay_log_integration.dart +++ b/packages/flutter/lib/src/integrations/replay_log_integration.dart @@ -25,13 +25,16 @@ class ReplayLogIntegration implements Integration { _options = options; _addReplayInformation = (OnBeforeCaptureLog event) { final scopeReplayId = hub.scope.replayId; - final replayId = _native?.replayId; + final replayId = scopeReplayId ?? _native?.replayId; + final replayIsBuffering = replayId != null && scopeReplayId == null; - if (sessionSampleRate > 0 && scopeReplayId != null) { + if (sessionSampleRate > 0 && replayId != null && !replayIsBuffering) { event.log.attributes['sentry.replay_id'] = SentryLogAttribute.string( scopeReplayId.toString(), ); - } else if (onErrorSampleRate > 0 && replayId != null) { + } else if (onErrorSampleRate > 0 && + replayId != null && + replayIsBuffering) { event.log.attributes['sentry.replay_id'] = SentryLogAttribute.string( replayId.toString(), ); diff --git a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart index c26b4515ed..ba43574745 100644 --- a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart +++ b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart @@ -35,17 +35,20 @@ class SentryNativeCocoa extends SentryNativeChannel { case 'captureReplayScreenshot': _replayRecorder ??= CocoaReplayRecorder(options); - final scopeReplayId = call.arguments['scope.replayId'] == null - ? null - : SentryId.fromId(call.arguments['scope.replayId'] as String); + final replayIdArg = call.arguments['replayId']; + final replayIsBuffering = + call.arguments['replayIsBuffering'] as bool? ?? false; - // TODO: We need the replayId from cocoa integration, so we can determine if we are in buffer mode. + final replayId = replayIdArg == null + ? null + : SentryId.fromId(replayIdArg as String); - if (_replayId != scopeReplayId) { - _replayId = scopeReplayId; + if (_replayId != replayId) { + _replayId = replayId; hub.configureScope((s) { + // Only set replay ID on scope if not buffering (active session mode) // ignore: invalid_use_of_internal_member - s.replayId = scopeReplayId; + s.replayId = !replayIsBuffering ? replayId : null; }); } diff --git a/packages/flutter/lib/src/native/java/sentry_native_java.dart b/packages/flutter/lib/src/native/java/sentry_native_java.dart index 43950a6bbe..1a88239827 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -34,21 +34,21 @@ class SentryNativeJava extends SentryNativeChannel { channel.setMethodCallHandler((call) async { switch (call.method) { case 'ReplayRecorder.start': - final scopeReplayIdArg = call.arguments['scope.replayId']; final replayIdArg = call.arguments['replayId']; + final replayIsBuffering = + call.arguments['replayIsBuffering'] as bool? ?? false; - final scopeReplayId = scopeReplayIdArg != null - ? SentryId.fromId(scopeReplayIdArg as String) - : null; - _replayId = replayIdArg != null + final replayId = replayIdArg != null ? SentryId.fromId(replayIdArg as String) : null; + _replayId = replayId; _replayRecorder = AndroidReplayRecorder.factory(options); await _replayRecorder!.start(); hub.configureScope((s) { + // Only set replay ID on scope if not buffering (active session mode) // ignore: invalid_use_of_internal_member - s.replayId = scopeReplayId; + s.replayId = !replayIsBuffering ? replayId : null; }); break; case 'ReplayRecorder.onConfigurationChanged': diff --git a/packages/flutter/test/integrations/replay_log_integration_test.dart b/packages/flutter/test/integrations/replay_log_integration_test.dart index 163bb7a678..f03fada734 100644 --- a/packages/flutter/test/integrations/replay_log_integration_test.dart +++ b/packages/flutter/test/integrations/replay_log_integration_test.dart @@ -61,6 +61,8 @@ void main() { final log = fixture.createTestLog(); await fixture.hub.captureLog(log); + // With sessionSampleRate = 0, scope replay ID should not be used + // (even though it's set, we're not in session mode) expect(log.attributes.containsKey('sentry.replay_id'), false); expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), false); @@ -80,55 +82,57 @@ void main() { final log = fixture.createTestLog(); await fixture.hub.captureLog(log); + // With sessionSampleRate = null (treated as 0), scope replay ID should not be used expect(log.attributes.containsKey('sentry.replay_id'), false); expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), false); }); test( - 'scope replayId takes precedence over native replayId when sessionSampleRate > 0', + 'uses replay_id when set on scope and sessionSampleRate > 0 (active session mode)', () async { final integration = fixture.getSut(); fixture.options.replay.sessionSampleRate = 0.5; fixture.options.replay.onErrorSampleRate = 0.5; - fixture.hub.scope.replayId = SentryId.fromId('scope-replay-id'); + final replayId = SentryId.fromId('test-replay-id'); + fixture.hub.scope.replayId = replayId; - // Mock native replayId to simulate buffering mode - final nativeReplayId = SentryId.fromId('native-replay-id'); - when(fixture.nativeBinding.replayId).thenReturn(nativeReplayId); + // Mock native replayId with the same value (same replay, just also set on scope) + when(fixture.nativeBinding.replayId).thenReturn(replayId); await integration.call(fixture.hub, fixture.options); final log = fixture.createTestLog(); await fixture.hub.captureLog(log); - // Should use scope replayId, not native replayId - expect(log.attributes['sentry.replay_id']?.value, 'scopereplayid'); + // Should use the replay ID from scope (active session mode) + expect(log.attributes['sentry.replay_id']?.value, 'testreplayid'); expect(log.attributes['sentry.replay_id']?.type, 'string'); - // Should NOT add buffering flag when scope replayId is used + // Should NOT add buffering flag when replay is active on scope expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), false); }); test( - 'adds replay_id and buffering flag when replay is in buffer mode (native replayId set)', + 'adds replay_id and buffering flag when replay is in buffer mode (scope null, native has ID)', () async { final integration = fixture.getSut(); fixture.options.replay.onErrorSampleRate = 0.5; + // Scope replay ID is null (default), so we're in buffer mode - // Mock native replayId to simulate buffering mode (Android) - final nativeReplayId = SentryId.fromId('native-replay-id'); - when(fixture.nativeBinding.replayId).thenReturn(nativeReplayId); + // Mock native replayId to simulate buffering mode (same replay, not on scope yet) + final replayId = SentryId.fromId('test-replay-id'); + when(fixture.nativeBinding.replayId).thenReturn(replayId); await integration.call(fixture.hub, fixture.options); final log = fixture.createTestLog(); await fixture.hub.captureLog(log); - // In buffering mode, both replay_id and buffering flag should be present - expect(log.attributes['sentry.replay_id']?.value, 'nativereplayid'); + // In buffering mode, use native replay ID and add buffering flag + expect(log.attributes['sentry.replay_id']?.value, 'testreplayid'); expect(log.attributes['sentry.replay_id']?.type, 'string'); expect( @@ -143,17 +147,18 @@ void main() { final integration = fixture.getSut(); fixture.options.replay.sessionSampleRate = 0.5; // Needed to enable replay fixture.options.replay.onErrorSampleRate = 0.0; + // Scope replay ID is null (default) - // Mock native replayId to simulate buffering mode - final nativeReplayId = SentryId.fromId('native-replay-id'); - when(fixture.nativeBinding.replayId).thenReturn(nativeReplayId); + // Mock native replayId to simulate what would be buffering mode + final replayId = SentryId.fromId('test-replay-id'); + when(fixture.nativeBinding.replayId).thenReturn(replayId); await integration.call(fixture.hub, fixture.options); final log = fixture.createTestLog(); await fixture.hub.captureLog(log); - // When onErrorSampleRate is 0, native replayId should be ignored + // When onErrorSampleRate is 0, native replayId should be ignored even if it exists expect(log.attributes.containsKey('sentry.replay_id'), false); expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), false); @@ -165,40 +170,42 @@ void main() { final integration = fixture.getSut(); fixture.options.replay.sessionSampleRate = 0.5; // Needed to enable replay fixture.options.replay.onErrorSampleRate = null; + // Scope replay ID is null (default) - // Mock native replayId to simulate buffering mode - final nativeReplayId = SentryId.fromId('native-replay-id'); - when(fixture.nativeBinding.replayId).thenReturn(nativeReplayId); + // Mock native replayId to simulate what would be buffering mode + final replayId = SentryId.fromId('test-replay-id'); + when(fixture.nativeBinding.replayId).thenReturn(replayId); await integration.call(fixture.hub, fixture.options); final log = fixture.createTestLog(); await fixture.hub.captureLog(log); - // When onErrorSampleRate is null, native replayId should be ignored + // When onErrorSampleRate is null (treated as 0), native replayId should be ignored expect(log.attributes.containsKey('sentry.replay_id'), false); expect(log.attributes.containsKey('sentry._internal.replay_is_buffering'), false); }); test( - 'does not add native replayId when scope replayId is not set and sessionSampleRate is 0', + 'adds replay_id when scope is null but native has ID and onErrorSampleRate > 0 (buffer mode)', () async { final integration = fixture.getSut(); fixture.options.replay.sessionSampleRate = 0.0; fixture.options.replay.onErrorSampleRate = 0.5; + // Scope replay ID is null (default), so we're in buffer mode - // Mock native replayId to simulate buffering mode - final nativeReplayId = SentryId.fromId('native-replay-id'); - when(fixture.nativeBinding.replayId).thenReturn(nativeReplayId); + // Mock native replayId to simulate buffering mode (replay exists but not on scope) + final replayId = SentryId.fromId('test-replay-id'); + when(fixture.nativeBinding.replayId).thenReturn(replayId); await integration.call(fixture.hub, fixture.options); final log = fixture.createTestLog(); await fixture.hub.captureLog(log); - // When scope replayId is not set but onErrorSampleRate > 0, should use native replayId - expect(log.attributes['sentry.replay_id']?.value, 'nativereplayid'); + // When scope is null but native has replay ID, use it in buffer mode + 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, true); @@ -224,8 +231,8 @@ void main() { fixture.options.replay.onErrorSampleRate = 0.5; // Mock native replayId - final nativeReplayId = SentryId.fromId('native-replay-id'); - when(fixture.nativeBinding.replayId).thenReturn(nativeReplayId); + final replayId = SentryId.fromId('test-replay-id'); + when(fixture.nativeBinding.replayId).thenReturn(replayId); await integration.call(fixture.hub, fixture.options); diff --git a/packages/flutter/test/replay/replay_native_test.dart b/packages/flutter/test/replay/replay_native_test.dart index 917c606fad..978d467bcd 100644 --- a/packages/flutter/test/replay/replay_native_test.dart +++ b/packages/flutter/test/replay/replay_native_test.dart @@ -77,16 +77,12 @@ void main() { verifyNever(hub.configureScope(any)); when(hub.configureScope(captureAny)).thenReturn(null); - // Android expects both 'scope.replayId' and 'replayId' - // iOS expects 'scope.replayId' - final replayConfig = mockPlatform.isAndroid - ? { - 'scope.replayId': '123', - 'replayId': '456', // internal replay ID for buffering - } - : { - 'scope.replayId': '123', - }; + // Both platforms now use 'replayId' and 'replayIsBuffering' + // replayIsBuffering: false means replay ID should be set on scope (active session) + final replayConfig = { + 'replayId': '123', + 'replayIsBuffering': false, + }; // emulate the native platform invoking the method final future = native.invokeFromNative( From 34436c7511c09ff8335c98e4d8dc4923e8e8fd5e Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 15 Oct 2025 12:59:04 +0200 Subject: [PATCH 14/16] curreclty check if in buffering mode --- .../io/sentry/flutter/SentryFlutterReplayRecorder.kt | 3 ++- packages/flutter/example/lib/main.dart | 2 +- .../flutter/lib/src/native/java/sentry_native_java.dart | 8 ++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt index a665fcbfe0..bdb0c8f1db 100644 --- a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt +++ b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt @@ -5,6 +5,7 @@ import android.os.Looper import android.util.Log import io.flutter.plugin.common.MethodChannel import io.sentry.Sentry +import io.sentry.protocol.SentryId import io.sentry.android.replay.Recorder import io.sentry.android.replay.ReplayIntegration import io.sentry.android.replay.ScreenshotRecorderConfig @@ -20,7 +21,7 @@ internal class SentryFlutterReplayRecorder( var replayIsBuffering = false Sentry.configureScope { scope -> // Buffering mode: we have a replay ID but it's not set on scope yet - replayIsBuffering = replayId != null && scope.replayId == null + replayIsBuffering = scope.replayId == SentryId.EMPTY_ID } channel.invokeMethod( "ReplayRecorder.start", diff --git a/packages/flutter/example/lib/main.dart b/packages/flutter/example/lib/main.dart index 0f936268d9..ff7810c58b 100644 --- a/packages/flutter/example/lib/main.dart +++ b/packages/flutter/example/lib/main.dart @@ -87,7 +87,7 @@ Future setupSentry( options.maxRequestBodySize = MaxRequestBodySize.always; options.navigatorKey = navigatorKey; - options.replay.sessionSampleRate = 1.0; + options.replay.sessionSampleRate = 0.0; options.replay.onErrorSampleRate = 1.0; options.enableLogs = true; diff --git a/packages/flutter/lib/src/native/java/sentry_native_java.dart b/packages/flutter/lib/src/native/java/sentry_native_java.dart index 1a88239827..c7f14cc121 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -35,12 +35,16 @@ class SentryNativeJava extends SentryNativeChannel { switch (call.method) { case 'ReplayRecorder.start': final replayIdArg = call.arguments['replayId']; - final replayIsBuffering = - call.arguments['replayIsBuffering'] as bool? ?? false; + final replayIsBufferingArg = call.arguments['replayIsBuffering']; final replayId = replayIdArg != null ? SentryId.fromId(replayIdArg as String) : null; + + final replayIsBuffering = replayIsBufferingArg != null + ? replayIsBufferingArg as bool + : false; + _replayId = replayId; _replayRecorder = AndroidReplayRecorder.factory(options); From 2b5369fd531e510ebb4015f0461db966b4f0fdf3 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 15 Oct 2025 13:31:34 +0200 Subject: [PATCH 15/16] add override annotation --- packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart index ba43574745..ba5ceed0c5 100644 --- a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart +++ b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart @@ -72,6 +72,7 @@ class SentryNativeCocoa extends SentryNativeChannel { return replayId; } + @override Future close() async { await _envelopeSender?.close(); return super.close(); From 7980011fa7e716a76cea4031c90ddb2c606586a7 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 15 Oct 2025 15:48:31 +0200 Subject: [PATCH 16/16] fix cl --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b16a64e668..5462a616ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Refactor `AndroidReplayRecorder` to use the new worker isolate api [#3296](https://github.com/getsentry/sentry-dart/pull/3296/) - Offload `captureEnvelope` to background isolate for Cocoa and Android [#3232](https://github.com/getsentry/sentry-dart/pull/3232) +- Add `sentry.replay_id` to flutter logs ([#3257](https://github.com/getsentry/sentry-dart/pull/3257)) ## 9.7.0 @@ -27,7 +28,6 @@ ### Enhancements - Prefix firebase remote config feature flags with `firebase:` ([#3258](https://github.com/getsentry/sentry-dart/pull/3258)) -- Add `sentry.replay_id` to flutter logs ([#3257](https://github.com/getsentry/sentry-dart/pull/3257)) - Replay: continue processing if encountering `InheritedWidget` ([#3200](https://github.com/getsentry/sentry-dart/pull/3200)) - Prevents false debug warnings when using [provider](https://pub.dev/packages/provider) for example which extensively uses `InheritedWidget` - Add `DioException` response data to error breadcrumb ([#3164](https://github.com/getsentry/sentry-dart/pull/3164))