diff --git a/CHANGELOG.md b/CHANGELOG.md index b5871b6734..4caf809621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Features + +- Synchronize `traceId` to native SDKs ([#3507](https://github.com/getsentry/sentry-dart/pull/3507)) + - Native events (e.g. from Android or iOS) such as errors, logs, and spans now share the same trace as Dart events, enabling unified trace views across layers + ### Dependencies - Bump Android SDK from v8.31.0 to v8.32.0 ([#3506](https://github.com/getsentry/sentry-dart/pull/3506)) diff --git a/packages/dart/lib/src/hub.dart b/packages/dart/lib/src/hub.dart index 8ffc57d15b..77a1c8581e 100644 --- a/packages/dart/lib/src/hub.dart +++ b/packages/dart/lib/src/hub.dart @@ -609,7 +609,12 @@ class Hub { void generateNewTrace() { // Create a brand-new trace and reset the sampling flag and sampleRand so // that the next root transaction can set it again. - scope.propagationContext.resetTrace(); + scope.propagationContext.generateNewTrace(); + // Fire-and-forget the callback + // Native SDK synchronization over async method channels may be slightly delayed, but this is not problematic in practice. + _options.lifecycleRegistry.dispatchCallback(OnGenerateNewTrace( + scope.propagationContext.traceId, + getSpan()?.context.spanId ?? SpanId.newId())); } /// Gets the current active transaction or span. diff --git a/packages/dart/lib/src/propagation_context.dart b/packages/dart/lib/src/propagation_context.dart index b75a897383..167bf761be 100644 --- a/packages/dart/lib/src/propagation_context.dart +++ b/packages/dart/lib/src/propagation_context.dart @@ -39,7 +39,7 @@ class PropagationContext { double? sampleRand; /// Starts a brand-new trace (new ID, new sampling value & sampled state). - void resetTrace() { + void generateNewTrace() { traceId = SentryId.newId(); sampleRand = null; _sampled = null; diff --git a/packages/dart/lib/src/sdk_lifecycle_hooks.dart b/packages/dart/lib/src/sdk_lifecycle_hooks.dart index 36912dc523..219e8ff49d 100644 --- a/packages/dart/lib/src/sdk_lifecycle_hooks.dart +++ b/packages/dart/lib/src/sdk_lifecycle_hooks.dart @@ -103,3 +103,12 @@ class OnProcessMetric extends SdkLifecycleEvent { OnProcessMetric(this.metric); } + +/// Dispatched when a new trace is generated via [Hub.generateNewTrace]. +@internal +class OnGenerateNewTrace extends SdkLifecycleEvent { + final SentryId traceId; + final SpanId spanId; + + OnGenerateNewTrace(this.traceId, this.spanId); +} diff --git a/packages/dart/test/hub_test.dart b/packages/dart/test/hub_test.dart index a5dcc6e1c5..475e057497 100644 --- a/packages/dart/test/hub_test.dart +++ b/packages/dart/test/hub_test.dart @@ -637,6 +637,68 @@ void main() { final newSampleRand = hub.scope.propagationContext.sampleRand; expect(newSampleRand, isNull); }); + + test('generateNewTrace dispatches OnTraceReset with traceId', () { + SentryId? receivedTraceId; + hub.options.lifecycleRegistry + .registerCallback((event) { + receivedTraceId = event.traceId; + }); + + hub.generateNewTrace(); + + expect(receivedTraceId, isNotNull); + expect(receivedTraceId, hub.scope.propagationContext.traceId); + }); + + test( + 'generateNewTrace dispatches OnTraceReset with spanId from active span', + () { + SpanId? receivedSpanId; + hub.options.tracesSampleRate = 1.0; + hub.options.lifecycleRegistry + .registerCallback((event) { + receivedSpanId = event.spanId; + }); + + hub.startTransaction('name', 'op', bindToScope: true); + hub.generateNewTrace(); + + expect(receivedSpanId, isNotNull); + expect(receivedSpanId, hub.getSpan()?.context.spanId); + }); + + test( + 'generateNewTrace dispatches OnTraceReset with new spanId if no active span', + () { + SpanId? receivedSpanId; + hub.options.tracesSampleRate = 1.0; + hub.options.lifecycleRegistry + .registerCallback((event) { + receivedSpanId = event.spanId; + }); + + hub.generateNewTrace(); + + expect(hub.getSpan(), isNull); + expect(receivedSpanId, isNotNull); + }); + + test( + 'generateNewTrace dispatches OnTraceReset with new spanId if tracing is disabled', + () { + SpanId? receivedSpanId; + hub.options.tracesSampleRate = null; + hub.options.lifecycleRegistry + .registerCallback((event) { + receivedSpanId = event.spanId; + }); + + hub.generateNewTrace(); + + expect(hub.getSpan(), isNull); + expect(receivedSpanId, isNotNull); + }); }); group('Hub scope callback', () { diff --git a/packages/dart/test/propagation_context_test.dart b/packages/dart/test/propagation_context_test.dart index 22347faa3a..7f128728ab 100644 --- a/packages/dart/test/propagation_context_test.dart +++ b/packages/dart/test/propagation_context_test.dart @@ -126,7 +126,7 @@ void main() { sut.sampleRand = 1.0; sut.applySamplingDecision(true); - sut.resetTrace(); + sut.generateNewTrace(); expect(sut.traceId, isNot(traceId)); expect(sut.sampleRand, isNull); diff --git a/packages/flutter/example/integration_test/integration_test.dart b/packages/flutter/example/integration_test/integration_test.dart index 4d7f9dce72..787f47a80a 100644 --- a/packages/flutter/example/integration_test/integration_test.dart +++ b/packages/flutter/example/integration_test/integration_test.dart @@ -279,6 +279,7 @@ void main() { (p) => p.name == package.name && p.version == package.version); expect(findMatchingPackage, isNotNull); } + expect(androidOptions.isEnableAutoTraceIdGeneration(), isFalse); final androidProxy = androidOptions.getProxy(); expect(androidProxy, isNotNull); expect(androidProxy!.getHost()?.toDartString(), 'proxy.local'); @@ -1065,6 +1066,43 @@ void main() { } }); + // We currently only test this on Android + // Setting up iOS for testing this is a big time effort so we rely on manually testing there for now + testWidgets('setTrace syncs Dart traceId to native Android scope', + (tester) async { + await restoreFlutterOnErrorAfter(() async { + await setupSentryAndApp(tester); + }); + + final dartTraceId = + Sentry.currentHub.scope.propagationContext.traceId.toString(); + + final traceParent = jni.Sentry.getTraceparent(); + expect(traceParent, isNotNull, + reason: 'Native traceparent should not be null'); + final traceHeader = traceParent!.getValue().toDartString(); + + final nativeTraceId = traceHeader.split('-').first; + expect(nativeTraceId, dartTraceId, + reason: 'Native traceId should match Dart traceId after initial sync'); + + Sentry.currentHub.generateNewTrace(); + final newDartTraceId = + Sentry.currentHub.scope.propagationContext.traceId.toString(); + + // Allow the fire-and-forget dispatch to complete + await Future.delayed(const Duration(milliseconds: 100)); + + final newTraceParent = + jni.Sentry.getTraceparent()?.getValue().toDartString(); + final newTraceHeader = newTraceParent!.toString(); + + final newNativeTraceId = newTraceHeader.split('-').first; + expect(newNativeTraceId, newDartTraceId, + reason: + 'Native traceId should match new Dart traceId after generateNewTrace'); + }, skip: !Platform.isAndroid); + group('e2e', () { var output = find.byKey(const Key('output')); late Fixture fixture; diff --git a/packages/flutter/ffi-jni.yaml b/packages/flutter/ffi-jni.yaml index 2a12b989c2..4a7a804a65 100644 --- a/packages/flutter/ffi-jni.yaml +++ b/packages/flutter/ffi-jni.yaml @@ -40,6 +40,7 @@ classes: - io.sentry.protocol.SentryPackage - io.sentry.rrweb.RRWebOptionsEvent - io.sentry.rrweb.RRWebEvent + - io.sentry.SentryTraceHeader - java.net.Proxy - android.graphics.Bitmap - android.content.Context diff --git a/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter/SentryFlutterPlugin.swift b/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter/SentryFlutterPlugin.swift index e2b370d29e..a894c8daf5 100644 --- a/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter/SentryFlutterPlugin.swift +++ b/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter/SentryFlutterPlugin.swift @@ -161,6 +161,12 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin { result(nil) #endif + case "setTrace": + let arguments = call.arguments as? [String: Any?] + let traceId = arguments?["traceId"] as? String + let spanId = arguments?["spanId"] as? String + setTrace(traceId: traceId, spanId: spanId, result: result) + default: result(FlutterMethodNotImplemented) } @@ -696,6 +702,18 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin { result("") } + private func setTrace(traceId: String?, spanId: String?, result: @escaping FlutterResult) { + guard let traceId = traceId, let spanId = spanId else { + print("Cannot set trace: traceId or spanId is null") + result(FlutterError(code: "10", message: "Cannot set trace: traceId or spanId is null", details: nil)) + return + } + let sentryTraceId = SentryId(uuidString: traceId) + let sentrySpanId = SpanId(value: spanId) + PrivateSentrySDKOnly.setTrace(sentryTraceId, spanId: sentrySpanId) + result("") + } + private func crash() { SentrySDK.crash() } diff --git a/packages/flutter/lib/src/integrations/native_trace_sync_integration.dart b/packages/flutter/lib/src/integrations/native_trace_sync_integration.dart new file mode 100644 index 0000000000..53308cb348 --- /dev/null +++ b/packages/flutter/lib/src/integrations/native_trace_sync_integration.dart @@ -0,0 +1,46 @@ +// ignore_for_file: invalid_use_of_internal_member + +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import '../../sentry_flutter.dart'; +import '../native/sentry_native_binding.dart'; +import '../utils/internal_logger.dart'; + +@internal +class NativeTraceSyncIntegration implements Integration { + static const integrationName = 'NativeTraceSync'; + final SentryNativeBinding _native; + SentryOptions? _options; + + NativeTraceSyncIntegration(this._native); + + @override + void call(Hub hub, SentryFlutterOptions options) { + if (!options.enableNativeTraceSync) { + internalLogger.info('$integrationName: disabled, skipping setup'); + return; + } + + _options = options; + options.lifecycleRegistry + .registerCallback(_syncTraceToNative); + options.sdk.addIntegration(integrationName); + + final traceId = hub.scope.propagationContext.traceId; + final spanId = hub.getSpan()?.context.spanId ?? SpanId.newId(); + + // Sync the initial PropagationContext created at Hub construction. + _syncTraceToNative(OnGenerateNewTrace(traceId, spanId)); + } + + @override + void close() { + _options?.lifecycleRegistry + .removeCallback(_syncTraceToNative); + } + + FutureOr _syncTraceToNative(OnGenerateNewTrace event) => + _native.setTrace(event.traceId, event.spanId); +} diff --git a/packages/flutter/lib/src/native/c/sentry_native.dart b/packages/flutter/lib/src/native/c/sentry_native.dart index 85c0b602c6..1d082cd6d9 100644 --- a/packages/flutter/lib/src/native/c/sentry_native.dart +++ b/packages/flutter/lib/src/native/c/sentry_native.dart @@ -310,6 +310,14 @@ class SentryNative with SentryNativeSafeInvoker implements SentryNativeBinding { FutureOr updateSession({int? errors, String? status}) { _logNotSupported('updating session'); } + + @override + bool get supportsTraceSync => false; + + @override + FutureOr setTrace(SentryId traceId, SpanId spanId) { + _logNotSupported('setting trace'); + } } extension on binding.sentry_value_u { diff --git a/packages/flutter/lib/src/native/java/binding.dart b/packages/flutter/lib/src/native/java/binding.dart index fab00f2e94..249a58c6ac 100644 --- a/packages/flutter/lib/src/native/java/binding.dart +++ b/packages/flutter/lib/src/native/java/binding.dart @@ -8181,10 +8181,10 @@ class Sentry extends jni$_.JObject { /// from: `static public io.sentry.SentryTraceHeader getTraceparent()` /// The returned object must be released after use, by calling the [release] method. - static jni$_.JObject? getTraceparent() { + static SentryTraceHeader? getTraceparent() { return _getTraceparent( _class.reference.pointer, _id_getTraceparent as jni$_.JMethodIDPtr) - .object(const jni$_.JObjectNullableType()); + .object(const $SentryTraceHeader$NullableType()); } static final _id_getBaggage = _class.staticMethodId( @@ -31510,10 +31510,10 @@ class ScopesAdapter extends jni$_.JObject { /// from: `public io.sentry.SentryTraceHeader getTraceparent()` /// The returned object must be released after use, by calling the [release] method. - jni$_.JObject? getTraceparent() { + SentryTraceHeader? getTraceparent() { return _getTraceparent( reference.pointer, _id_getTraceparent as jni$_.JMethodIDPtr) - .object(const jni$_.JObjectNullableType()); + .object(const $SentryTraceHeader$NullableType()); } static final _id_getBaggage = _class.instanceMethodId( @@ -38704,6 +38704,298 @@ final class $RRWebEvent$Type extends jni$_.JObjType { } } +/// from: `io.sentry.SentryTraceHeader` +class SentryTraceHeader extends jni$_.JObject { + @jni$_.internal + @core$_.override + final jni$_.JObjType $type; + + @jni$_.internal + SentryTraceHeader.fromReference( + jni$_.JReference reference, + ) : $type = type, + super.fromReference(reference); + + static final _class = jni$_.JClass.forName(r'io/sentry/SentryTraceHeader'); + + /// The type which includes information such as the signature of this class. + static const nullableType = $SentryTraceHeader$NullableType(); + static const type = $SentryTraceHeader$Type(); + static final _id_SENTRY_TRACE_HEADER = _class.staticFieldId( + r'SENTRY_TRACE_HEADER', + r'Ljava/lang/String;', + ); + + /// from: `static public final java.lang.String SENTRY_TRACE_HEADER` + /// The returned object must be released after use, by calling the [release] method. + static jni$_.JString? get SENTRY_TRACE_HEADER => + _id_SENTRY_TRACE_HEADER.get(_class, const jni$_.JStringNullableType()); + + static final _id_new$ = _class.constructorId( + r'(Lio/sentry/protocol/SentryId;Lio/sentry/SpanId;Ljava/lang/Boolean;)V', + ); + + static final _new$ = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + jni$_.VarArgs< + ( + jni$_.Pointer, + jni$_.Pointer, + jni$_.Pointer + )>)>>('globalEnv_NewObject') + .asFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + jni$_.Pointer, + jni$_.Pointer, + jni$_.Pointer)>(); + + /// from: `public void (io.sentry.protocol.SentryId sentryId, io.sentry.SpanId spanId, java.lang.Boolean boolean)` + /// The returned object must be released after use, by calling the [release] method. + factory SentryTraceHeader( + SentryId sentryId, + jni$_.JObject spanId, + jni$_.JBoolean? boolean, + ) { + final _$sentryId = sentryId.reference; + final _$spanId = spanId.reference; + final _$boolean = boolean?.reference ?? jni$_.jNullReference; + return SentryTraceHeader.fromReference(_new$( + _class.reference.pointer, + _id_new$ as jni$_.JMethodIDPtr, + _$sentryId.pointer, + _$spanId.pointer, + _$boolean.pointer) + .reference); + } + + static final _id_new$1 = _class.constructorId( + r'(Ljava/lang/String;)V', + ); + + static final _new$1 = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + jni$_.VarArgs<(jni$_.Pointer,)>)>>( + 'globalEnv_NewObject') + .asFunction< + jni$_.JniResult Function(jni$_.Pointer, + jni$_.JMethodIDPtr, jni$_.Pointer)>(); + + /// from: `public void (java.lang.String string)` + /// The returned object must be released after use, by calling the [release] method. + factory SentryTraceHeader.new$1( + jni$_.JString string, + ) { + final _$string = string.reference; + return SentryTraceHeader.fromReference(_new$1(_class.reference.pointer, + _id_new$1 as jni$_.JMethodIDPtr, _$string.pointer) + .reference); + } + + static final _id_getName = _class.instanceMethodId( + r'getName', + r'()Ljava/lang/String;', + ); + + static final _getName = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>>('globalEnv_CallObjectMethod') + .asFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>(); + + /// from: `public java.lang.String getName()` + /// The returned object must be released after use, by calling the [release] method. + jni$_.JString getName() { + return _getName(reference.pointer, _id_getName as jni$_.JMethodIDPtr) + .object(const jni$_.JStringType()); + } + + static final _id_getValue = _class.instanceMethodId( + r'getValue', + r'()Ljava/lang/String;', + ); + + static final _getValue = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>>('globalEnv_CallObjectMethod') + .asFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>(); + + /// from: `public java.lang.String getValue()` + /// The returned object must be released after use, by calling the [release] method. + jni$_.JString getValue() { + return _getValue(reference.pointer, _id_getValue as jni$_.JMethodIDPtr) + .object(const jni$_.JStringType()); + } + + static final _id_getTraceId = _class.instanceMethodId( + r'getTraceId', + r'()Lio/sentry/protocol/SentryId;', + ); + + static final _getTraceId = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>>('globalEnv_CallObjectMethod') + .asFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>(); + + /// from: `public io.sentry.protocol.SentryId getTraceId()` + /// The returned object must be released after use, by calling the [release] method. + SentryId getTraceId() { + return _getTraceId(reference.pointer, _id_getTraceId as jni$_.JMethodIDPtr) + .object(const $SentryId$Type()); + } + + static final _id_getSpanId = _class.instanceMethodId( + r'getSpanId', + r'()Lio/sentry/SpanId;', + ); + + static final _getSpanId = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>>('globalEnv_CallObjectMethod') + .asFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>(); + + /// from: `public io.sentry.SpanId getSpanId()` + /// The returned object must be released after use, by calling the [release] method. + jni$_.JObject getSpanId() { + return _getSpanId(reference.pointer, _id_getSpanId as jni$_.JMethodIDPtr) + .object(const jni$_.JObjectType()); + } + + static final _id_isSampled = _class.instanceMethodId( + r'isSampled', + r'()Ljava/lang/Boolean;', + ); + + static final _isSampled = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>>('globalEnv_CallObjectMethod') + .asFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>(); + + /// from: `public java.lang.Boolean isSampled()` + /// The returned object must be released after use, by calling the [release] method. + jni$_.JBoolean? isSampled() { + return _isSampled(reference.pointer, _id_isSampled as jni$_.JMethodIDPtr) + .object(const jni$_.JBooleanNullableType()); + } +} + +final class $SentryTraceHeader$NullableType + extends jni$_.JObjType { + @jni$_.internal + const $SentryTraceHeader$NullableType(); + + @jni$_.internal + @core$_.override + String get signature => r'Lio/sentry/SentryTraceHeader;'; + + @jni$_.internal + @core$_.override + SentryTraceHeader? fromReference(jni$_.JReference reference) => + reference.isNull + ? null + : SentryTraceHeader.fromReference( + reference, + ); + @jni$_.internal + @core$_.override + jni$_.JObjType get superType => const jni$_.JObjectNullableType(); + + @jni$_.internal + @core$_.override + jni$_.JObjType get nullableType => this; + + @jni$_.internal + @core$_.override + final superCount = 1; + + @core$_.override + int get hashCode => ($SentryTraceHeader$NullableType).hashCode; + + @core$_.override + bool operator ==(Object other) { + return other.runtimeType == ($SentryTraceHeader$NullableType) && + other is $SentryTraceHeader$NullableType; + } +} + +final class $SentryTraceHeader$Type extends jni$_.JObjType { + @jni$_.internal + const $SentryTraceHeader$Type(); + + @jni$_.internal + @core$_.override + String get signature => r'Lio/sentry/SentryTraceHeader;'; + + @jni$_.internal + @core$_.override + SentryTraceHeader fromReference(jni$_.JReference reference) => + SentryTraceHeader.fromReference( + reference, + ); + @jni$_.internal + @core$_.override + jni$_.JObjType get superType => const jni$_.JObjectNullableType(); + + @jni$_.internal + @core$_.override + jni$_.JObjType get nullableType => + const $SentryTraceHeader$NullableType(); + + @jni$_.internal + @core$_.override + final superCount = 1; + + @core$_.override + int get hashCode => ($SentryTraceHeader$Type).hashCode; + + @core$_.override + bool operator ==(Object other) { + return other.runtimeType == ($SentryTraceHeader$Type) && + other is $SentryTraceHeader$Type; + } +} + /// from: `java.net.Proxy$Type` class Proxy$Type extends jni$_.JObject { @jni$_.internal 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 5f56f35040..a57ea31a47 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -388,6 +388,26 @@ class SentryNativeJava extends SentryNativeChannel { replayConfig.release(); }); + + @override + bool get supportsTraceSync => true; + + @override + void setTrace(SentryId traceId, SpanId spanId) { + tryCatchSync('setTrace', () { + using((arena) { + final jTraceId = traceId.toString().toJString()..releasedBy(arena); + final jSpanId = spanId.toString().toJString()..releasedBy(arena); + // The two double parameters are sampleRate and sampleRand. + // We pass null for them because we don't need to support sampleRate and sampleRand. + // sampleRate and sampleRand are only used by native for baggage headers + // on outgoing HTTP requests. Since HTTP requests in Flutter go through + // Dart, the Dart-side propagation context handles baggage already. + // When there is a use case for sampleRate and sampleRand, we can add support for them. + native.InternalSentrySdk.setTrace(jTraceId, jSpanId, null, null); + }); + }); + } } @visibleForTesting diff --git a/packages/flutter/lib/src/native/java/sentry_native_java_init.dart b/packages/flutter/lib/src/native/java/sentry_native_java_init.dart index 98af364019..86004d5bc3 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java_init.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java_init.dart @@ -239,6 +239,12 @@ void configureAndroidOptions({ } androidOptions.setSendDefaultPii(options.sendDefaultPii); androidOptions.setEnableScopeSync(options.enableNdkScopeSync); + // When trace sync is enabled, Dart is the source of truth for propagation + // context and pushes it to native via setTrace. Disable native auto + // generation so it doesn't overwrite the Dart-provided trace ID. + if (options.enableNativeTraceSync) { + androidOptions.setEnableAutoTraceIdGeneration(false); + } androidOptions .setProguardUuid(options.proguardUuid?.toJString()?..releasedBy(arena)); androidOptions.setEnableSpotlight(options.spotlight.enabled); diff --git a/packages/flutter/lib/src/native/sentry_native_binding.dart b/packages/flutter/lib/src/native/sentry_native_binding.dart index bf45423d25..1efd177fe1 100644 --- a/packages/flutter/lib/src/native/sentry_native_binding.dart +++ b/packages/flutter/lib/src/native/sentry_native_binding.dart @@ -93,4 +93,10 @@ abstract class SentryNativeBinding { /// /// NNote: This is used on web platforms and is a no-op on non-web. FutureOr captureSession(); + + /// Whether the native SDK supports syncing the trace. + bool get supportsTraceSync; + + /// Sets the trace context on the native SDK scope. + FutureOr setTrace(SentryId traceId, SpanId spanId); } diff --git a/packages/flutter/lib/src/native/sentry_native_channel.dart b/packages/flutter/lib/src/native/sentry_native_channel.dart index 10726fdc3a..baed785529 100644 --- a/packages/flutter/lib/src/native/sentry_native_channel.dart +++ b/packages/flutter/lib/src/native/sentry_native_channel.dart @@ -422,4 +422,21 @@ class SentryNativeChannel FutureOr updateSession({int? errors, String? status}) { _logNotSupported('updating session'); } + + // Android handles supporting trace sync via JNI, not method channels. + @override + bool get supportsTraceSync => !options.platform.isAndroid; + + @override + FutureOr setTrace(SentryId traceId, SpanId spanId) { + if (options.platform.isAndroid) { + assert(false, + 'setTrace should not be used through method channels on Android.'); + return null; + } + return channel.invokeMethod('setTrace', { + 'traceId': traceId.toString(), + 'spanId': spanId.toString(), + }); + } } diff --git a/packages/flutter/lib/src/sentry_flutter.dart b/packages/flutter/lib/src/sentry_flutter.dart index 3ec4d43cd9..1c51b0666e 100644 --- a/packages/flutter/lib/src/sentry_flutter.dart +++ b/packages/flutter/lib/src/sentry_flutter.dart @@ -25,6 +25,7 @@ import 'integrations/native_app_start_handler.dart'; import 'integrations/replay_telemetry_integration.dart'; import 'integrations/screenshot_integration.dart'; import 'integrations/generic_app_start_integration.dart'; +import 'integrations/native_trace_sync_integration.dart'; import 'integrations/thread_info_integration.dart'; import 'integrations/web_session_integration.dart'; import 'native/factory.dart'; @@ -189,6 +190,9 @@ mixin SentryFlutter { // We also need to call this before the native sdk integrations so release is properly propagated. integrations.add(LoadReleaseIntegration()); integrations.add(createSdkIntegration(native)); + if (native.supportsTraceSync) { + integrations.add(NativeTraceSyncIntegration(native)); + } integrations.add(createLoadDebugImagesIntegration(native)); if (!platform.isWeb) { if (native.supportsLoadContexts) { diff --git a/packages/flutter/lib/src/sentry_flutter_options.dart b/packages/flutter/lib/src/sentry_flutter_options.dart index 0de7aebfe6..d88afa0209 100644 --- a/packages/flutter/lib/src/sentry_flutter_options.dart +++ b/packages/flutter/lib/src/sentry_flutter_options.dart @@ -284,6 +284,13 @@ class SentryFlutterOptions extends SentryOptions { /// you must use `SentryWidgetsFlutterBinding.ensureInitialized()` instead. bool enableFramesTracking = true; + /// Whether to synchronize the Dart trace to the native SDK. + /// + /// Allows native events to share the same trace as Dart events. + /// + /// Supported on Android and iOS/macOS. + bool enableNativeTraceSync = true; + /// Replay recording configuration. final replay = SentryReplayOptions(); diff --git a/packages/flutter/lib/src/web/sentry_web.dart b/packages/flutter/lib/src/web/sentry_web.dart index 05fe22e09b..b55a12c99b 100644 --- a/packages/flutter/lib/src/web/sentry_web.dart +++ b/packages/flutter/lib/src/web/sentry_web.dart @@ -257,6 +257,11 @@ class SentryWeb with SentryNativeSafeInvoker implements SentryNativeBinding { _logNotSupported('set user'); } + @override + FutureOr setTrace(SentryId traceId, SpanId spanId) { + _logNotSupported('setting trace'); + } + @override int? startProfiler(SentryId traceId) { _logNotSupported('start profiler'); @@ -275,6 +280,9 @@ class SentryWeb with SentryNativeSafeInvoker implements SentryNativeBinding { @override SentryId? get replayId => null; + @override + bool get supportsTraceSync => false; + @override SentryFlutterOptions get options => _options; } diff --git a/packages/flutter/test/integrations/native_trace_sync_integration_test.dart b/packages/flutter/test/integrations/native_trace_sync_integration_test.dart new file mode 100644 index 0000000000..d1f1c1d6c1 --- /dev/null +++ b/packages/flutter/test/integrations/native_trace_sync_integration_test.dart @@ -0,0 +1,101 @@ +// ignore_for_file: invalid_use_of_internal_member + +@TestOn('vm') +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/integrations/native_trace_sync_integration.dart'; +import 'package:sentry_flutter/src/native/sentry_native_binding.dart'; + +import '../mocks.dart'; + +void main() { + group(NativeTraceSyncIntegration, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('adds integration', () { + fixture.registerIntegration(); + + expect(fixture.options.sdk.integrations, + contains(NativeTraceSyncIntegration.integrationName)); + }); + + test('does not add integration if enableNativeTraceSync is false', () { + fixture.options.enableNativeTraceSync = false; + fixture.registerIntegration(); + + expect(fixture.options.sdk.integrations, + isNot(contains(NativeTraceSyncIntegration.integrationName))); + }); + + test('does not sync trace if enableNativeTraceSync is false', () { + fixture.options.enableNativeTraceSync = false; + fixture.registerIntegration(); + + fixture.hub.generateNewTrace(); + + expect(fixture.binding.setTraceCalls, isEmpty); + }); + + test('syncs initial propagation context on registration', () { + fixture.registerIntegration(); + + final call = fixture.binding.setTraceCalls.single; + expect(call.traceId, fixture.hub.scope.propagationContext.traceId); + }); + + test('syncs trace when OnTraceReset is dispatched', () { + fixture.registerIntegration(); + fixture.binding.setTraceCalls.clear(); + + fixture.hub.generateNewTrace(); + + // We cannot assert that the trace propagated to native here + // instead we just assert that the call was made with the correct trace ID. + final call = fixture.binding.setTraceCalls.single; + expect(call.traceId, fixture.hub.scope.propagationContext.traceId); + }); + + test('unregisters callback on close', () { + fixture.registerIntegration(); + fixture.binding.setTraceCalls.clear(); + + fixture.sut.close(); + fixture.hub.generateNewTrace(); + + expect(fixture.binding.setTraceCalls, isEmpty); + }); + }); +} + +class Fixture { + final options = defaultTestOptions(); + final binding = _FakeNativeBinding(); + late final hub = Hub(options); + late final sut = NativeTraceSyncIntegration(binding); + + void registerIntegration() { + sut.call(hub, options); + } +} + +class _SetTraceCall { + final SentryId traceId; + final SpanId spanId; + + _SetTraceCall(this.traceId, this.spanId); +} + +class _FakeNativeBinding extends Fake implements SentryNativeBinding { + final setTraceCalls = <_SetTraceCall>[]; + + @override + void setTrace(SentryId traceId, SpanId spanId) { + setTraceCalls.add(_SetTraceCall(traceId, spanId)); + } +} diff --git a/packages/flutter/test/mocks.dart b/packages/flutter/test/mocks.dart index 7c59da2b7f..5081e79508 100644 --- a/packages/flutter/test/mocks.dart +++ b/packages/flutter/test/mocks.dart @@ -184,6 +184,7 @@ class NativeChannelFixture { handler = MockCallbacks().methodCallHandler; when(handler('initNativeSdk', any)).thenAnswer((_) => Future.value()); when(handler('closeNativeSdk', any)).thenAnswer((_) => Future.value()); + when(handler('setTrace', any)).thenAnswer((_) => Future.value()); _messenger.setMockMethodCallHandler( channel, (call) => handler(call.method, call.arguments)); } diff --git a/packages/flutter/test/mocks.mocks.dart b/packages/flutter/test/mocks.mocks.dart index db45428c78..33c864a792 100644 --- a/packages/flutter/test/mocks.mocks.dart +++ b/packages/flutter/test/mocks.mocks.dart @@ -4,14 +4,14 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i12; -import 'dart:developer' as _i23; -import 'dart:typed_data' as _i19; +import 'dart:developer' as _i22; +import 'dart:typed_data' as _i18; import 'dart:ui' as _i6; import 'package:flutter/foundation.dart' as _i8; import 'package:flutter/gestures.dart' as _i7; import 'package:flutter/rendering.dart' as _i10; -import 'package:flutter/scheduler.dart' as _i22; +import 'package:flutter/scheduler.dart' as _i21; import 'package:flutter/services.dart' as _i4; import 'package:flutter/src/widgets/_window.dart' as _i11; import 'package:flutter/src/widgets/binding.dart' as _i5; @@ -21,19 +21,18 @@ import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/src/dummies.dart' as _i15; import 'package:sentry/src/profiling.dart' as _i16; import 'package:sentry/src/sentry_tracer.dart' as _i3; -import 'package:sentry/src/telemetry/metric/metric.dart' as _i17; import 'package:sentry_flutter/sentry_flutter.dart' as _i2; import 'package:sentry_flutter/src/frames_tracking/sentry_delayed_frames_tracker.dart' - as _i21; -import 'package:sentry_flutter/src/native/sentry_native_binding.dart' as _i18; + as _i20; +import 'package:sentry_flutter/src/native/sentry_native_binding.dart' as _i17; import 'package:sentry_flutter/src/navigation/time_to_display_tracker.dart' - as _i25; + as _i24; import 'package:sentry_flutter/src/navigation/time_to_full_display_tracker.dart' - as _i27; -import 'package:sentry_flutter/src/navigation/time_to_initial_display_tracker.dart' as _i26; -import 'package:sentry_flutter/src/replay/replay_config.dart' as _i20; -import 'package:sentry_flutter/src/web/sentry_js_binding.dart' as _i24; +import 'package:sentry_flutter/src/navigation/time_to_initial_display_tracker.dart' + as _i25; +import 'package:sentry_flutter/src/replay/replay_config.dart' as _i19; +import 'package:sentry_flutter/src/web/sentry_js_binding.dart' as _i23; import 'mocks.dart' as _i14; @@ -1767,7 +1766,7 @@ class MockSentryClient extends _i1.Mock implements _i2.SentryClient { @override _i12.Future captureMetric( - _i17.SentryMetric? metric, { + _i2.SentryMetric? metric, { _i2.Scope? scope, }) => (super.noSuchMethod( @@ -1880,7 +1879,7 @@ class MockMethodChannel extends _i1.Mock implements _i4.MethodChannel { /// /// See the documentation for Mockito's code generation for more information. class MockSentryNativeBinding extends _i1.Mock - implements _i18.SentryNativeBinding { + implements _i17.SentryNativeBinding { MockSentryNativeBinding() { _i1.throwOnMissingStub(this); } @@ -1903,6 +1902,12 @@ class MockSentryNativeBinding extends _i1.Mock returnValue: false, ) as bool); + @override + bool get supportsTraceSync => (super.noSuchMethod( + Invocation.getter(#supportsTraceSync), + returnValue: false, + ) as bool); + @override _i12.FutureOr init(_i2.Hub? hub) => (super.noSuchMethod(Invocation.method( @@ -1912,7 +1917,7 @@ class MockSentryNativeBinding extends _i1.Mock @override _i12.FutureOr captureEnvelope( - _i19.Uint8List? envelopeData, + _i18.Uint8List? envelopeData, bool? containsUnhandledException, ) => (super.noSuchMethod(Invocation.method( @@ -2035,7 +2040,7 @@ class MockSentryNativeBinding extends _i1.Mock )) as _i12.FutureOr?>); @override - _i12.FutureOr setReplayConfig(_i20.ReplayConfig? config) => + _i12.FutureOr setReplayConfig(_i19.ReplayConfig? config) => (super.noSuchMethod(Invocation.method( #setReplayConfig, [config], @@ -2063,25 +2068,38 @@ class MockSentryNativeBinding extends _i1.Mock [], {#ignoreDuration: ignoreDuration}, )) as _i12.FutureOr); + + @override + _i12.FutureOr setTrace( + _i2.SentryId? traceId, + _i2.SpanId? spanId, + ) => + (super.noSuchMethod(Invocation.method( + #setTrace, + [ + traceId, + spanId, + ], + )) as _i12.FutureOr); } /// A class which mocks [SentryDelayedFramesTracker]. /// /// See the documentation for Mockito's code generation for more information. class MockSentryDelayedFramesTracker extends _i1.Mock - implements _i21.SentryDelayedFramesTracker { + implements _i20.SentryDelayedFramesTracker { MockSentryDelayedFramesTracker() { _i1.throwOnMissingStub(this); } @override - List<_i21.SentryFrameTiming> get delayedFrames => (super.noSuchMethod( + List<_i20.SentryFrameTiming> get delayedFrames => (super.noSuchMethod( Invocation.getter(#delayedFrames), - returnValue: <_i21.SentryFrameTiming>[], - ) as List<_i21.SentryFrameTiming>); + returnValue: <_i20.SentryFrameTiming>[], + ) as List<_i20.SentryFrameTiming>); @override - List<_i21.SentryFrameTiming> getFramesIntersecting({ + List<_i20.SentryFrameTiming> getFramesIntersecting({ required DateTime? startTimestamp, required DateTime? endTimestamp, }) => @@ -2094,8 +2112,8 @@ class MockSentryDelayedFramesTracker extends _i1.Mock #endTimestamp: endTimestamp, }, ), - returnValue: <_i21.SentryFrameTiming>[], - ) as List<_i21.SentryFrameTiming>); + returnValue: <_i20.SentryFrameTiming>[], + ) as List<_i20.SentryFrameTiming>); @override void addDelayedFrame( @@ -2124,7 +2142,7 @@ class MockSentryDelayedFramesTracker extends _i1.Mock ); @override - _i21.SpanFrameMetrics? getFrameMetrics({ + _i20.SpanFrameMetrics? getFrameMetrics({ required DateTime? spanStartTimestamp, required DateTime? spanEndTimestamp, }) => @@ -2135,7 +2153,7 @@ class MockSentryDelayedFramesTracker extends _i1.Mock #spanStartTimestamp: spanStartTimestamp, #spanEndTimestamp: spanEndTimestamp, }, - )) as _i21.SpanFrameMetrics?); + )) as _i20.SpanFrameMetrics?); @override void clear() => super.noSuchMethod( @@ -2274,14 +2292,14 @@ class MockWidgetsFlutterBinding extends _i1.Mock ); @override - _i22.SchedulingStrategy get schedulingStrategy => (super.noSuchMethod( + _i21.SchedulingStrategy get schedulingStrategy => (super.noSuchMethod( Invocation.getter(#schedulingStrategy), returnValue: ({ required int priority, - required _i22.SchedulerBinding scheduler, + required _i21.SchedulerBinding scheduler, }) => false, - ) as _i22.SchedulingStrategy); + ) as _i21.SchedulingStrategy); @override int get transientCallbackCount => (super.noSuchMethod( @@ -2302,10 +2320,10 @@ class MockWidgetsFlutterBinding extends _i1.Mock ) as bool); @override - _i22.SchedulerPhase get schedulerPhase => (super.noSuchMethod( + _i21.SchedulerPhase get schedulerPhase => (super.noSuchMethod( Invocation.getter(#schedulerPhase), - returnValue: _i22.SchedulerPhase.idle, - ) as _i22.SchedulerPhase); + returnValue: _i21.SchedulerPhase.idle, + ) as _i21.SchedulerPhase); @override bool get framesEnabled => (super.noSuchMethod( @@ -2332,7 +2350,7 @@ class MockWidgetsFlutterBinding extends _i1.Mock ) as Duration); @override - set schedulingStrategy(_i22.SchedulingStrategy? value) => super.noSuchMethod( + set schedulingStrategy(_i21.SchedulingStrategy? value) => super.noSuchMethod( Invocation.setter( #schedulingStrategy, value, @@ -2929,10 +2947,10 @@ class MockWidgetsFlutterBinding extends _i1.Mock @override _i12.Future scheduleTask( - _i22.TaskCallback? task, - _i22.Priority? priority, { + _i21.TaskCallback? task, + _i21.Priority? priority, { String? debugLabel, - _i23.Flow? flow, + _i22.Flow? flow, }) => (super.noSuchMethod( Invocation.method( @@ -2990,7 +3008,7 @@ class MockWidgetsFlutterBinding extends _i1.Mock @override int scheduleFrameCallback( - _i22.FrameCallback? callback, { + _i21.FrameCallback? callback, { bool? rescheduling = false, bool? scheduleNewFrame = true, }) => @@ -3044,7 +3062,7 @@ class MockWidgetsFlutterBinding extends _i1.Mock ) as bool); @override - void addPersistentFrameCallback(_i22.FrameCallback? callback) => + void addPersistentFrameCallback(_i21.FrameCallback? callback) => super.noSuchMethod( Invocation.method( #addPersistentFrameCallback, @@ -3055,7 +3073,7 @@ class MockWidgetsFlutterBinding extends _i1.Mock @override void addPostFrameCallback( - _i22.FrameCallback? callback, { + _i21.FrameCallback? callback, { String? debugLabel = 'callback', }) => super.noSuchMethod( @@ -3131,12 +3149,12 @@ class MockWidgetsFlutterBinding extends _i1.Mock ); @override - _i22.PerformanceModeRequestHandle? requestPerformanceMode( + _i21.PerformanceModeRequestHandle? requestPerformanceMode( _i6.DartPerformanceMode? mode) => (super.noSuchMethod(Invocation.method( #requestPerformanceMode, [mode], - )) as _i22.PerformanceModeRequestHandle?); + )) as _i21.PerformanceModeRequestHandle?); @override void handleDrawFrame() => super.noSuchMethod( @@ -3725,7 +3743,7 @@ class MockWidgetsFlutterBinding extends _i1.Mock /// A class which mocks [SentryJsBinding]. /// /// See the documentation for Mockito's code generation for more information. -class MockSentryJsBinding extends _i1.Mock implements _i24.SentryJsBinding { +class MockSentryJsBinding extends _i1.Mock implements _i23.SentryJsBinding { MockSentryJsBinding() { _i1.throwOnMissingStub(this); } @@ -3797,7 +3815,7 @@ class MockSentryJsBinding extends _i1.Mock implements _i24.SentryJsBinding { /// /// See the documentation for Mockito's code generation for more information. class MockTimeToDisplayTracker extends _i1.Mock - implements _i25.TimeToDisplayTracker { + implements _i24.TimeToDisplayTracker { MockTimeToDisplayTracker() { _i1.throwOnMissingStub(this); } @@ -3884,7 +3902,7 @@ class MockTimeToDisplayTracker extends _i1.Mock /// /// See the documentation for Mockito's code generation for more information. class MockTimeToInitialDisplayTracker extends _i1.Mock - implements _i26.TimeToInitialDisplayTracker { + implements _i25.TimeToInitialDisplayTracker { MockTimeToInitialDisplayTracker() { _i1.throwOnMissingStub(this); } @@ -3920,7 +3938,7 @@ class MockTimeToInitialDisplayTracker extends _i1.Mock /// /// See the documentation for Mockito's code generation for more information. class MockTimeToFullDisplayTracker extends _i1.Mock - implements _i27.TimeToFullDisplayTracker { + implements _i26.TimeToFullDisplayTracker { MockTimeToFullDisplayTracker() { _i1.throwOnMissingStub(this); } @@ -4160,7 +4178,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { )) as _i12.FutureOr); @override - _i12.Future captureMetric(_i17.SentryMetric? metric) => + _i12.Future captureMetric(_i2.SentryMetric? metric) => (super.noSuchMethod( Invocation.method( #captureMetric,