diff --git a/sdk/flutter/example/lib/main.dart b/sdk/flutter/example/lib/main.dart index 148fdf09b6..b7d28e18e4 100644 --- a/sdk/flutter/example/lib/main.dart +++ b/sdk/flutter/example/lib/main.dart @@ -8,6 +8,7 @@ import 'package:launchdarkly_flutter_observability/launchdarkly_flutter_observab import 'click_scenarios.dart'; import 'getx_app.dart'; import 'my_app.dart'; +import 'repro.dart'; class LDSingleton { static LDClient? client; @@ -61,6 +62,7 @@ void main() { // Mirrors the two `LDObserve.Init(...)` variants in // sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs. _startObservability(); + applyGlobalAttributes(); // Report any errors handled by flutter. FlutterError.onError = (FlutterErrorDetails details) { diff --git a/sdk/flutter/example/lib/my_home_page.dart b/sdk/flutter/example/lib/my_home_page.dart index 63eea493e0..c109881844 100644 --- a/sdk/flutter/example/lib/my_home_page.dart +++ b/sdk/flutter/example/lib/my_home_page.dart @@ -14,6 +14,7 @@ import 'getx_app.dart' show useGetX; import 'getx_scenarios.dart'; import 'main.dart'; import 'nested_masking_propagation_page.dart'; +import 'repro.dart'; import 'smoothie_list_page.dart'; import 'web_view_page.dart'; @@ -242,9 +243,11 @@ class _MyHomePageState extends State { 'test-integer': 42, 'test-double': 3.14, 'test-array': [3.14, 6.28], + 'utm_source': 'per_call', }, ); debugPrint('Log triggered'); + printLogExpectation(); } // The MAUI sample captures the span context inside a span and passes it to @@ -325,6 +328,7 @@ class _MyHomePageState extends State { span.addEvent('download.completed'); span.end(); debugPrint('Custom span sent: $spanName'); + printSpanExpectation(); } void _onStartPolling() { diff --git a/sdk/flutter/example/lib/repro.dart b/sdk/flutter/example/lib/repro.dart new file mode 100644 index 0000000000..2edc21e84d --- /dev/null +++ b/sdk/flutter/example/lib/repro.dart @@ -0,0 +1,24 @@ +import 'package:flutter/foundation.dart'; +import 'package:launchdarkly_flutter_observability/launchdarkly_flutter_observability.dart'; + +// Repro #1: global attributes (e.g. UTM params) on all telemetry. +// +// [applyGlobalAttributes] runs at startup right after `LDObserve.init` is +// called, before observability has finished booting, so these attributes are +// set before init. The "Send custom span" and "Trigger Log" buttons print what +// to look for on the resulting rows in LaunchDarkly. + +const globalAttributes = { + 'utm_campaign': 'spring_promo', + 'utm_source': 'email', +}; + +void applyGlobalAttributes() => LDObserve.setAttributes(globalAttributes); + +void printSpanExpectation() => + debugPrint('[#1] expect utm_campaign=spring_promo on this row'); + +void printLogExpectation() { + debugPrint('[#1] expect utm_campaign=spring_promo on this row'); + debugPrint('[#1] expect utm_source=per_call (per-call wins over global)'); +} diff --git a/sdk/flutter/packages/observability/README.md b/sdk/flutter/packages/observability/README.md index b02c7abae8..a40c93d656 100644 --- a/sdk/flutter/packages/observability/README.md +++ b/sdk/flutter/packages/observability/README.md @@ -456,6 +456,8 @@ Limitations worth knowing: | `LDObserve.trackScreenView(name, {screenClass, screenId, category, properties})` | Record a screen view (navigation) as a `screen_view` span and a Session Replay `Navigate` event. Prefer `LDNavigatorObserver` for ordinary route changes. | | `LDObserve.trackClick({id, tag, text, x, y, properties})` | Record a click as a `click` span and a Session Replay `Click` event, for interactions automatic capture cannot observe. `x`/`y` are `double` logical pixels. | | `LDObserve.shutdown()` | Returns a `Future`. Flush buffered spans, remove the Dart instrumentations, stop session replay, and turn every recording API into a no-op. Terminal: a later `init` does nothing. On Android and iOS the native SDK's automatic instrumentation (crashes, network, launch times) keeps running, because it has no teardown. | +| `LDObserve.setAttributes(map)` | Merge [global attributes](#global-attributes) attached to every span, log, and event recorded afterwards. | +| `LDObserve.removeAttributes(keys)` | Remove global attributes by key. | | `LDObserve.zoneSpecification()` | A zone spec that forwards `print`/`debugPrint` output as logs. | | `LDNavigatorObserver({screenNameExtractor, category})` | A `NavigatorObserver` that reports each route change as a screen view. | | `LDRoutePatterns.extractor(patterns, {skipUnmatched})` | A `screenNameExtractor` that reports the route pattern a navigation matched, so `/orders/42` becomes `/orders/:id`. | @@ -487,6 +489,28 @@ span.setAttributes({ Methods that accept `properties` (`recordLog`, `recordException`, `startSpan`, `track`, `trackScreenView`, `trackClick`) take a `Map` of these same plain values. +### Global attributes + +`LDObserve.setAttributes` attaches attributes to everything recorded from then on: spans (manual and automatic, including flag evaluations, errors, and app lifecycle), logs, screen views, clicks, and track events. Calls merge by key, so a later call replaces only the keys it names. `LDObserve.removeAttributes` removes keys. + +```dart +LDObserve.setAttributes({ + 'tenant.id': 'acme', + 'app.flavor': 'beta', +}); + +// Later, for example on sign-out: +LDObserve.removeAttributes(['tenant.id']); +``` + +- **Per-call values win.** A key passed in `properties`, or set on a span with `setAttribute`, keeps its own value; a global attribute only fills keys that are not already set. Global attributes in turn take precedence over the `ObservabilityOptions.attributes` resource attributes. +- **A span gets the global attributes current when it starts.** +- **Safe before init.** Attributes set before `LDObserve.init` completes apply once observability is running. +- **In memory only.** They last for the life of the process, are kept across session changes, and are never persisted. +- **Session keys are reserved.** `session.id` and `highlight.session_id` are managed by the SDK and ignored here, as are values that cannot be attributes. + +On iOS and Android, telemetry the native SDK records on its own (crash reports, launch times, native lifecycle spans, and metrics) does not carry global attributes. The Flutter SDK has no Dart metrics API, so there are no Dart metrics for them to apply to. + ## Session replay Session Replay captures screen recordings to help you understand how users interact with your application. Enable it by passing `SessionReplayOptions` to `LDObserve.init` / `LDObserve.initStandalone`, and wrap the part of your app you want recorded in a `SessionReplayCapture` widget: diff --git a/sdk/flutter/packages/observability/lib/src/ld_observe.dart b/sdk/flutter/packages/observability/lib/src/ld_observe.dart index beb4756c82..24a152d31a 100644 --- a/sdk/flutter/packages/observability/lib/src/ld_observe.dart +++ b/sdk/flutter/packages/observability/lib/src/ld_observe.dart @@ -236,6 +236,34 @@ final class LDObserve { attributes: attributesFromProperties(properties), ); + /// Merge [attributes] into the global attributes attached to every span, + /// log and event recorded from now on, including automatic ones (flag + /// evaluations, errors, lifecycle, screen views, clicks, track events). + /// + /// Keys merge with those already set; a later call replaces a key's value. + /// Attributes passed to an individual call, or set on a span, win over a + /// global attribute with the same key. A span takes the global attributes + /// current when it starts. + /// + /// Values follow the same rules as `properties`: strings, numbers, booleans, + /// or homogeneous lists of those; anything else (including `null`) is + /// ignored. The SDK-managed session keys `session.id` and + /// `highlight.session_id` are ignored. + /// + /// Safe to call before [init]: the attributes apply once observability is + /// running. They are kept in memory for the life of the process, survive + /// session changes, and are never persisted. + /// + /// On iOS and Android, telemetry the native SDK records on its own (crashes, + /// launch times, native lifecycle, metrics) does not carry them. + static void setAttributes(Map attributes) => + ObserveOtel.setAttributes(attributes); + + /// Remove [keys] from the global attributes set with [setAttributes]. + /// Keys that are not set are ignored. + static void removeAttributes(List keys) => + ObserveOtel.removeAttributes(keys); + /// Get a zone specification which intercepts print statements. static ZoneSpecification zoneSpecification() => ObserveOtel.zoneSpecification(); diff --git a/sdk/flutter/packages/observability/lib/src/observe_otel.dart b/sdk/flutter/packages/observability/lib/src/observe_otel.dart index 351ac6355c..5b181829b2 100644 --- a/sdk/flutter/packages/observability/lib/src/observe_otel.dart +++ b/sdk/flutter/packages/observability/lib/src/observe_otel.dart @@ -11,6 +11,7 @@ import 'api/span.dart'; import 'api/span_kind.dart'; import 'api/span_status_code.dart'; import 'otel/conversions.dart'; +import 'otel/global_attributes.dart'; import 'otel/setup.dart'; import 'platform/ld_observe_platform.dart'; import 'plugin/ld_observe_plugin.dart'; @@ -268,6 +269,15 @@ final class ObserveOtel { ); } + /// Merges [properties] into the global attributes applied to every span, + /// log and event recorded afterwards. + static void setAttributes(Map properties) => + GlobalAttributes.set(properties); + + /// Removes [keys] from the global attributes. + static void removeAttributes(List keys) => + GlobalAttributes.remove(keys); + /// Whether [shutdown] has run. Terminal: a later boot does nothing. static bool get isShutdown => _shutdown; diff --git a/sdk/flutter/packages/observability/lib/src/otel/exporters/exporter_factory_io.dart b/sdk/flutter/packages/observability/lib/src/otel/exporters/exporter_factory_io.dart index c5556973df..6d88e3a8f2 100644 --- a/sdk/flutter/packages/observability/lib/src/otel/exporters/exporter_factory_io.dart +++ b/sdk/flutter/packages/observability/lib/src/otel/exporters/exporter_factory_io.dart @@ -16,6 +16,7 @@ import '../../api/log_severity.dart'; import '../../plugin/observability_config.dart'; import '../../platform/io/messages.g.dart' as wire; import '../conversions.dart'; +import '../global_attributes.dart'; import 'exporter_factory.dart'; /// Creates the native (iOS/Android) implementation of [ObservabilityExporters]. @@ -89,7 +90,7 @@ class _NativeClickRecorder implements ClickRecorder { x, y, timestampMillis, - properties, + GlobalAttributes.fillProperties(properties), ), ); } @@ -118,7 +119,13 @@ class _NativeScreenViewRecorder implements ScreenViewRecorder { Map? properties, }) { unawaited( - _api.trackScreenView(name, screenClass, screenId, category, properties), + _api.trackScreenView( + name, + screenClass, + screenId, + category, + GlobalAttributes.fillProperties(properties), + ), ); } } @@ -172,7 +179,9 @@ class _NativeTrackRecorder implements TrackRecorder { unawaited( _api.track( eventName, - json is Map ? Map.from(json) : null, + GlobalAttributes.fillProperties( + json is Map ? Map.from(json) : null, + ), metricValue?.toDouble(), contextKeys, ), @@ -274,7 +283,7 @@ class _NativeLogRecorder implements LogRecorder { Map? attributes, }) { final attrs = {}; - attributes?.forEach((key, attribute) { + GlobalAttributes.fillAttributes(attributes)?.forEach((key, attribute) { final converted = convertAttribute(key, attribute); if (converted != null) { attrs[key] = converted.value; diff --git a/sdk/flutter/packages/observability/lib/src/otel/exporters/exporter_factory_stub.dart b/sdk/flutter/packages/observability/lib/src/otel/exporters/exporter_factory_stub.dart index db799307b5..e1404c731a 100644 --- a/sdk/flutter/packages/observability/lib/src/otel/exporters/exporter_factory_stub.dart +++ b/sdk/flutter/packages/observability/lib/src/otel/exporters/exporter_factory_stub.dart @@ -12,6 +12,7 @@ import '../../api/log_severity.dart'; import '../../plugin/observability_config.dart'; import '../click_convention.dart'; import '../conversions.dart'; +import '../global_attributes.dart'; import '../log_convention.dart'; import '../screen_view_convention.dart'; import '../track_convention.dart'; @@ -204,7 +205,9 @@ class _SpanEventLogRecorder implements LogRecorder { final span = tracer.startSpan(LogConvention.spanName); span.addEvent( LogConvention.eventName, - attributes: convertAttributes(combinedAttributes), + attributes: convertAttributes( + GlobalAttributes.fillAttributes(combinedAttributes)!, + ), ); span.end(); } diff --git a/sdk/flutter/packages/observability/lib/src/otel/exporters/exporter_factory_web.dart b/sdk/flutter/packages/observability/lib/src/otel/exporters/exporter_factory_web.dart index b3fa42480a..b44eabf4c4 100644 --- a/sdk/flutter/packages/observability/lib/src/otel/exporters/exporter_factory_web.dart +++ b/sdk/flutter/packages/observability/lib/src/otel/exporters/exporter_factory_web.dart @@ -13,6 +13,7 @@ import '../../api/log_severity.dart'; import '../../plugin/observability_config.dart'; import '../click_convention.dart'; import '../conversions.dart'; +import '../global_attributes.dart'; import '../log_convention.dart'; import '../screen_view_convention.dart'; import '../track_convention.dart'; @@ -208,7 +209,9 @@ class _SpanEventLogRecorder implements LogRecorder { final span = tracer.startSpan(LogConvention.spanName); span.addEvent( LogConvention.eventName, - attributes: convertAttributes(combinedAttributes), + attributes: convertAttributes( + GlobalAttributes.fillAttributes(combinedAttributes)!, + ), ); span.end(); } diff --git a/sdk/flutter/packages/observability/lib/src/otel/global_attributes.dart b/sdk/flutter/packages/observability/lib/src/otel/global_attributes.dart new file mode 100644 index 0000000000..b9e360d336 --- /dev/null +++ b/sdk/flutter/packages/observability/lib/src/otel/global_attributes.dart @@ -0,0 +1,150 @@ +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:opentelemetry/api.dart' as otel; +import 'package:opentelemetry/sdk.dart' + show ReadOnlySpan, ReadWriteSpan, SpanProcessor; + +import '../api/attribute.dart'; +import 'conversions.dart'; + +/// The process-wide attributes set through `LDObserve.setAttributes`, applied +/// to every span, log and forwarded event recorded after the change. +/// +/// Readers only ever see an immutable snapshot, rebuilt on each change, so the +/// recording hot path does no copying or conversion. The store is independent +/// of the pipeline: it can be written before init, survives session rotation +/// (which happens natively and never reaches it), and is never persisted. +/// +/// Not for export. +final class GlobalAttributes { + GlobalAttributes._(); + + /// Keys the SDK manages for session correlation. Setting them globally would + /// misattribute telemetry to another session, so they are ignored. + static const reservedKeys = {'session.id', 'highlight.session_id'}; + + static _Snapshot _snapshot = _Snapshot.empty; + + /// The current attributes, keyed by name. + static Map get attributes => _snapshot.attributes; + + /// The current attributes as plain Dart values, for the native bridge. + static Map get values => _snapshot.values; + + /// The current attributes in OpenTelemetry form, for span processors. + static List get otelAttributes => _snapshot.otelAttributes; + + /// Whether no global attributes are set. + static bool get isEmpty => _snapshot.values.isEmpty; + + /// Merges [properties] into the global attributes by key. Reserved keys and + /// values that cannot be represented as an attribute are ignored. + static void set(Map properties) { + final next = Map.of(_snapshot.values); + var changed = false; + properties.forEach((key, value) { + if (reservedKeys.contains(key)) { + return; + } + final attribute = attributeFromNative(value); + final otelAttribute = convertAttribute(key, attribute); + if (otelAttribute == null) { + return; + } + next[key] = otelAttribute.value; + changed = true; + }); + if (changed) { + _snapshot = _Snapshot(next); + } + } + + /// Removes [keys] from the global attributes. + static void remove(List keys) { + if (keys.every((key) => !_snapshot.values.containsKey(key))) { + return; + } + final next = Map.of(_snapshot.values) + ..removeWhere((key, _) => keys.contains(key)); + _snapshot = _Snapshot(next); + } + + /// [attributes] with every global attribute whose key it lacks added. + /// Returns [attributes] itself when there is nothing to add. + static Map? fillAttributes( + Map? attributes, + ) { + final globals = _snapshot.attributes; + if (globals.isEmpty) { + return attributes; + } + return {...globals, ...?attributes}; + } + + /// [properties] with every global value whose key it lacks added. Returns + /// [properties] itself when there is nothing to add. + static Map? fillProperties( + Map? properties, + ) { + final globals = _snapshot.values; + if (globals.isEmpty) { + return properties; + } + return {...globals, ...?properties}; + } + + /// Clears every global attribute. + @visibleForTesting + static void resetForTesting() => _snapshot = _Snapshot.empty; +} + +/// Fills each starting span with the global attributes it does not already +/// carry. The tracer sets the attributes passed to `startSpan` before any +/// processor runs, so those always win. Registered ahead of the export +/// processors. +/// +/// Not for export. +final class GlobalAttributesSpanProcessor implements SpanProcessor { + @override + void onStart(ReadWriteSpan span, otel.Context parentContext) { + final globals = GlobalAttributes.otelAttributes; + if (globals.isEmpty) { + return; + } + final existing = span.attributes; + for (final attribute in globals) { + if (existing.get(attribute.key) == null) { + span.setAttribute(attribute); + } + } + } + + @override + void onEnd(ReadOnlySpan span) {} + + @override + void shutdown() {} + + @override + void forceFlush() {} +} + +final class _Snapshot { + factory _Snapshot(Map values) { + final attributes = values.map( + (key, value) => MapEntry(key, attributeFromNative(value)), + ); + return _Snapshot._( + Map.unmodifiable(values), + Map.unmodifiable(attributes), + List.unmodifiable(convertAttributes(attributes)), + ); + } + + _Snapshot._(this.values, this.attributes, this.otelAttributes); + + static final empty = _Snapshot(const {}); + + final Map values; + final Map attributes; + final List otelAttributes; +} diff --git a/sdk/flutter/packages/observability/lib/src/otel/setup.dart b/sdk/flutter/packages/observability/lib/src/otel/setup.dart index 108c6ec463..8272ff9f36 100644 --- a/sdk/flutter/packages/observability/lib/src/otel/setup.dart +++ b/sdk/flutter/packages/observability/lib/src/otel/setup.dart @@ -3,6 +3,7 @@ import 'package:launchdarkly_flutter_observability/src/api/attribute.dart' show StringAttribute; import 'package:launchdarkly_flutter_observability/src/otel/conversions.dart'; import 'package:launchdarkly_flutter_observability/src/otel/exporters/exporter_factory.dart'; +import 'package:launchdarkly_flutter_observability/src/otel/global_attributes.dart'; import 'package:launchdarkly_flutter_observability/src/otel/service_convention.dart'; import 'package:launchdarkly_flutter_observability/src/otel/symbols_id.dart'; import 'package:launchdarkly_flutter_observability/src/plugin/observability_config.dart'; @@ -123,7 +124,10 @@ class Otel { ObservabilityExporters exporters, ) { final tracerProvider = TracerProviderBase( - processors: exporters.createSpanProcessors(config), + processors: [ + GlobalAttributesSpanProcessor(), + ...exporters.createSpanProcessors(config), + ], resource: Resource( resourceAttributes(sdkKey, config, symbolsId: readSymbolsId()), ), diff --git a/sdk/flutter/packages/observability/test/global_attributes_pipeline_test.dart b/sdk/flutter/packages/observability/test/global_attributes_pipeline_test.dart new file mode 100644 index 0000000000..d99824ecd8 --- /dev/null +++ b/sdk/flutter/packages/observability/test/global_attributes_pipeline_test.dart @@ -0,0 +1,152 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:launchdarkly_flutter_observability/launchdarkly_flutter_observability.dart'; +import 'package:launchdarkly_flutter_observability/src/otel/global_attributes.dart'; +import 'package:launchdarkly_flutter_observability/src/otel/setup.dart'; +import 'package:launchdarkly_flutter_observability/src/platform/io/messages.g.dart' + as wire; +import 'package:launchdarkly_flutter_observability/src/plugin/observability_config.dart'; +import 'package:opentelemetry/api.dart' as otel; +import 'package:opentelemetry/sdk.dart' as sdk; + +import 'support/native_api_mock.dart'; + +/// Global attributes through the real pipeline, down to +/// the native bridge. In its own file because `Otel.setup` registers the global +/// tracer provider, which is allowed once per isolate. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final native = NativeApiMock(); + + setUpAll(() { + native.install(); + GlobalAttributes.resetForTesting(); + // Before setup: globals set ahead of init must still apply. + LDObserve.setAttributes({'tenant': 'acme'}); + Otel.setup('mobile-key', configWithDefaults()); + // Buffered by the batch processor until a test flushes it. + LDObserve.startSpan('early').end(); + }); + + setUp(() { + native.calls.clear(); + GlobalAttributes.resetForTesting(); + LDObserve.setAttributes({'tenant': 'acme'}); + }); + + tearDownAll(() { + Otel.shutdown(); + native.uninstall(); + GlobalAttributes.resetForTesting(); + }); + + Future> exportedSpans() async { + (otel.globalTracerProvider as sdk.TracerProviderBase).forceFlush(); + await pumpEventQueue(); + return [ + for (final call in native.callsTo('exportSpans')) + ...(call.single! as List).cast(), + ]; + } + + Future exportedSpan(String name) async => + (await exportedSpans()).singleWhere((s) => s.name == name); + + group('spans', () { + test('carry globals set before init', () async { + expect((await exportedSpan('early')).attributes?['tenant'], 'acme'); + }); + + test('keep attributes passed to the call over globals', () async { + LDObserve.setAttributes({'tier': 'global'}); + LDObserve.startSpan('per-call', properties: {'tier': 'call'}).end(); + + final attributes = (await exportedSpan('per-call')).attributes!; + expect(attributes['tier'], 'call'); + expect(attributes['tenant'], 'acme'); + }); + + test('keep attributes set on the span over globals', () async { + LDObserve.startSpan('set-later') + ..setAttribute('tenant', 'span') + ..end(); + + expect((await exportedSpan('set-later')).attributes?['tenant'], 'span'); + }); + + test('take the globals current when they start', () async { + final span = LDObserve.startSpan('long'); + LDObserve.setAttributes({'tenant': 'changed', 'late': true}); + span.end(); + + final attributes = (await exportedSpan('long')).attributes!; + expect(attributes['tenant'], 'acme'); + expect(attributes.containsKey('late'), isFalse); + }); + + test('stop carrying removed globals', () async { + LDObserve.removeAttributes(['tenant']); + LDObserve.startSpan('removed').end(); + + expect( + (await exportedSpan('removed')).attributes?.containsKey('tenant'), + isFalse, + ); + }); + + test('include automatic error spans', () async { + LDObserve.recordException(StateError('x')); + + final span = (await exportedSpans()).singleWhere( + (s) => s.name == 'launchdarkly.error', + ); + expect(span.attributes?['tenant'], 'acme'); + }); + }); + + group('logs', () { + Future recordedLog() async { + await pumpEventQueue(); + return native.callsTo('recordLog').single.single! as wire.LDLogRecord; + } + + test('carry globals, beneath the call properties', () async { + LDObserve.setAttributes({'tier': 'global'}); + LDObserve.recordLog('hi', properties: {'tier': 'call'}); + + final attributes = (await recordedLog()).attributes!; + expect(attributes['tenant'], 'acme'); + expect(attributes['tier'], 'call'); + }); + }); + + group('events forwarded to native', () { + test('screen views carry globals beneath their properties', () async { + LDObserve.setAttributes({'tier': 'global'}); + LDObserve.trackScreenView('Home', properties: {'tier': 'call'}); + await pumpEventQueue(); + + final properties = + native.callsTo('trackScreenView').single[4]! as Map; + expect(properties, {'tenant': 'acme', 'tier': 'call'}); + }); + + test('clicks carry globals', () async { + LDObserve.trackClick(id: 'buy'); + await pumpEventQueue(); + + final properties = + native.callsTo('trackClick').single[9]! as Map; + expect(properties, {'tenant': 'acme'}); + }); + + test('track events carry globals beneath their data', () async { + LDObserve.setAttributes({'plan': 'global'}); + LDObserve.track('purchase', properties: {'plan': 'call'}); + await pumpEventQueue(); + + final data = native.callsTo('track').single[1]! as Map; + expect(data, {'tenant': 'acme', 'plan': 'call'}); + }); + }); +} diff --git a/sdk/flutter/packages/observability/test/global_attributes_test.dart b/sdk/flutter/packages/observability/test/global_attributes_test.dart new file mode 100644 index 0000000000..c2d2a114e8 --- /dev/null +++ b/sdk/flutter/packages/observability/test/global_attributes_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:launchdarkly_flutter_observability/launchdarkly_flutter_observability.dart'; +import 'package:launchdarkly_flutter_observability/src/api/attribute.dart'; +import 'package:launchdarkly_flutter_observability/src/otel/global_attributes.dart'; + +/// The global attribute store, without a tracer provider. +void main() { + setUp(GlobalAttributes.resetForTesting); + tearDown(GlobalAttributes.resetForTesting); + + group('setAttributes / removeAttributes', () { + test('merge by key, later values replacing earlier ones', () { + LDObserve.setAttributes({'tenant': 'acme', 'tier': 'free'}); + LDObserve.setAttributes({'tier': 'pro', 'build': 42}); + + expect(GlobalAttributes.values, { + 'tenant': 'acme', + 'tier': 'pro', + 'build': 42, + }); + }); + + test('remove the given keys and ignore unknown ones', () { + LDObserve.setAttributes({'a': 1, 'b': 2, 'c': 3}); + LDObserve.removeAttributes(['a', 'c', 'missing']); + + expect(GlobalAttributes.values, {'b': 2}); + }); + + test('ignore the SDK-managed session keys', () { + LDObserve.setAttributes({ + 'session.id': 'spoofed', + 'highlight.session_id': 'spoofed', + 'kept': true, + }); + + expect(GlobalAttributes.values, {'kept': true}); + }); + + test('ignore values that cannot be attributes', () { + LDObserve.setAttributes({ + 'null': null, + 'nested': {'not': 'supported'}, + 'mixed': [1, 'a'], + 'list': ['a', 'b'], + 'ratio': 0.5, + }); + + expect(GlobalAttributes.values, { + 'list': ['a', 'b'], + 'ratio': 0.5, + }); + }); + + test('publish an immutable snapshot', () { + LDObserve.setAttributes({'a': 1}); + final snapshot = GlobalAttributes.values; + LDObserve.setAttributes({'b': 2}); + + expect(snapshot, {'a': 1}); + expect(() => (snapshot as Map)['c'] = 3, throwsUnsupportedError); + }); + }); + + group('fill', () { + test('adds only the keys a call does not set', () { + LDObserve.setAttributes({'k': 'global', 'g': 'global'}); + + expect(GlobalAttributes.fillProperties({'k': 'call'}), { + 'k': 'call', + 'g': 'global', + }); + final attributes = GlobalAttributes.fillAttributes({ + 'k': StringAttribute('call'), + })!; + expect((attributes['k']! as StringAttribute).value, 'call'); + expect((attributes['g']! as StringAttribute).value, 'global'); + }); + + test('returns the input untouched when nothing is set', () { + final properties = {'k': 'v'}; + expect(GlobalAttributes.fillProperties(properties), same(properties)); + expect(GlobalAttributes.fillProperties(null), isNull); + }); + }); +} diff --git a/sdk/flutter/packages/observability/test/public_api_test.dart b/sdk/flutter/packages/observability/test/public_api_test.dart index a9d71edddd..1b97054be0 100644 --- a/sdk/flutter/packages/observability/test/public_api_test.dart +++ b/sdk/flutter/packages/observability/test/public_api_test.dart @@ -74,6 +74,10 @@ void main() { Map? properties, }) recordLog = LDObserve.recordLog; + final void Function(Map) setAttributes = + LDObserve.setAttributes; + final void Function(List) removeAttributes = + LDObserve.removeAttributes; final ZoneSpecification Function() zoneSpecification = LDObserve.zoneSpecification; final Future Function() shutdown = LDObserve.shutdown; @@ -88,6 +92,8 @@ void main() { trackClick, recordException, recordLog, + setAttributes, + removeAttributes, zoneSpecification, shutdown, ], everyElement(isNotNull)); diff --git a/sdk/flutter/packages/observability/test/web_exporters_test.dart b/sdk/flutter/packages/observability/test/web_exporters_test.dart index d1ef93576f..0b9b0fb3bd 100644 --- a/sdk/flutter/packages/observability/test/web_exporters_test.dart +++ b/sdk/flutter/packages/observability/test/web_exporters_test.dart @@ -1,7 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:launchdarkly_flutter_observability/src/api/attribute.dart'; +import 'package:launchdarkly_flutter_observability/src/api/log_severity.dart'; import 'package:launchdarkly_flutter_observability/src/otel/exporters/exporter_factory.dart'; import 'package:launchdarkly_flutter_observability/src/otel/exporters/exporter_factory_web.dart' as web; +import 'package:launchdarkly_flutter_observability/src/otel/global_attributes.dart'; import 'package:launchdarkly_flutter_observability/src/otel/setup.dart'; import 'package:launchdarkly_flutter_observability/src/otel/symbols_id.dart'; import 'package:launchdarkly_flutter_observability/src/options/observability_options.dart'; @@ -35,11 +38,14 @@ void main() { setUpAll(() { api.registerGlobalTracerProvider( - sdk.TracerProviderBase(processors: [processor]), + sdk.TracerProviderBase( + processors: [GlobalAttributesSpanProcessor(), processor], + ), ); }); setUp(processor.ended.clear); + tearDown(GlobalAttributes.resetForTesting); void recordAll(ObservabilityConfig config) { exporters.createTrackRecorder(config).track('purchase'); @@ -83,6 +89,44 @@ void main() { ); expect(spanNames(), ['track', 'click']); }); + + test('carry globals without overriding the SDK attributes', () { + GlobalAttributes.set({'tenant': 'acme', 'event.type': 'global'}); + recordAll(configFromOptions(const ObservabilityOptions())); + exporters + .createLogRecorder(configFromOptions(const ObservabilityOptions())) + .recordLog('hi', severity: LogSeverity.info); + + expect(processor.ended, hasLength(4)); + for (final span in processor.ended) { + expect(span.attributes.get('tenant'), 'acme', reason: span.name); + } + final click = processor.ended.singleWhere((s) => s.name == 'click'); + expect(click.attributes.get('event.type'), 'click'); + }); + + test('add globals to the log event beneath the log and call keys', () { + GlobalAttributes.set({ + 'tenant': 'acme', + 'log.message': 'global', + 'log.severity': 'global', + 'tier': 'global', + }); + exporters + .createLogRecorder(configFromOptions(const ObservabilityOptions())) + .recordLog( + 'hi', + severity: LogSeverity.warn, + attributes: {'tier': StringAttribute('call')}, + ); + + final event = processor.ended.single.events.single; + final attributes = {for (final a in event.attributes) a.key: a.value}; + expect(attributes['tenant'], 'acme'); + expect(attributes['log.message'], 'hi'); + expect(attributes['log.severity'], 'warn'); + expect(attributes['tier'], 'call'); + }); }); group('resource attributes', () {