From 3b0ee4576de7c71fd628b30ac760ddc5538f6c56 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 28 Oct 2026 16:24:04 -0700 Subject: [PATCH 1/4] feat(flutter): add setAttributes and custom span/log processors Co-authored-by: Cursor --- sdk/flutter/packages/observability/README.md | 73 ++++++ .../launchdarkly_flutter_observability.dart | 1 + .../observability/lib/src/api/log_record.dart | 44 ++++ .../observability/lib/src/ld_observe.dart | 28 +++ .../observability/lib/src/observe_otel.dart | 22 +- .../src/options/observability_options.dart | 27 ++ .../lib/src/otel/conversions.dart | 17 ++ .../otel/exporters/exporter_factory_io.dart | 15 +- .../lib/src/otel/global_attributes.dart | 117 +++++++++ .../lib/src/otel/processors.dart | 198 +++++++++++++++ .../observability/lib/src/otel/setup.dart | 14 +- .../lib/src/plugin/observability_config.dart | 12 + .../test/global_attributes_pipeline_test.dart | 234 ++++++++++++++++++ .../test/global_attributes_test.dart | 169 +++++++++++++ .../observability/test/public_api_test.dart | 25 ++ .../test/web_exporters_test.dart | 23 +- 16 files changed, 1013 insertions(+), 6 deletions(-) create mode 100644 sdk/flutter/packages/observability/lib/src/api/log_record.dart create mode 100644 sdk/flutter/packages/observability/lib/src/otel/global_attributes.dart create mode 100644 sdk/flutter/packages/observability/lib/src/otel/processors.dart create mode 100644 sdk/flutter/packages/observability/test/global_attributes_pipeline_test.dart create mode 100644 sdk/flutter/packages/observability/test/global_attributes_test.dart diff --git a/sdk/flutter/packages/observability/README.md b/sdk/flutter/packages/observability/README.md index b02c7abae8..7fe9cb453c 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,77 @@ 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. + +### Custom span and log processors + +Pass `spanProcessors` and `logProcessors` to `ObservabilityOptions` to inspect or modify telemetry before it is exported — for example to add attributes computed at record time or to redact values. They run in order, after global attributes are applied (so they see them) and before the SDK exports the data, so their changes are what gets sent. A processor that throws is skipped for that span or log, and the data is still exported. + +`spanProcessors` takes OpenTelemetry `SpanProcessor`s from the [`opentelemetry`](https://pub.dev/packages/opentelemetry) package; add it to your `pubspec.yaml` (`opentelemetry: ^0.18.11`) to implement one. Attributes set in `onStart` are exported. `logProcessors` takes `LDLogRecordProcessor`s: the `opentelemetry` package has no logs SDK, so the Flutter SDK defines its own log record, `LDLogRecord`, whose `message`, `severity`, `stackTrace`, and `attributes` can all be changed. + +```dart +import 'package:launchdarkly_flutter_observability/launchdarkly_flutter_observability.dart'; +import 'package:opentelemetry/api.dart' as otel; +import 'package:opentelemetry/sdk.dart' as otel_sdk; + +class ScreenSpanProcessor implements otel_sdk.SpanProcessor { + @override + void onStart(otel_sdk.ReadWriteSpan span, otel.Context parentContext) { + span.setAttribute(otel.Attribute.fromString('app.screen', currentScreen)); + } + + @override + void onEnd(otel_sdk.ReadOnlySpan span) {} + + @override + void shutdown() {} + + @override + void forceFlush() {} +} + +class RedactEmailProcessor extends LDLogRecordProcessor { + @override + void onEmit(LDLogRecord record) { + record.message = record.message.replaceAll( + RegExp(r'[\w.+-]+@[\w-]+\.[\w.]+'), + '', + ); + record.attributes.remove('user.email'); + } +} + +LDObserve.initStandalone( + mobileKey, + observability: ObservabilityOptions( + spanProcessors: [ScreenSpanProcessor()], + logProcessors: [RedactEmailProcessor()], + ), +); +``` + +Processors see spans and logs recorded from Dart: `LDObserve` calls, flag evaluations, errors, Dart lifecycle spans, and captured `debugPrint`/`print` output, on every platform. On iOS and Android the `track`, `screen_view`, and `click` spans are created by the native SDK and do not pass through `spanProcessors` (they still carry global attributes), and neither do the native SDK's own spans and logs. + ## 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/launchdarkly_flutter_observability.dart b/sdk/flutter/packages/observability/lib/launchdarkly_flutter_observability.dart index 8b77007692..d7ccfbc27d 100644 --- a/sdk/flutter/packages/observability/lib/launchdarkly_flutter_observability.dart +++ b/sdk/flutter/packages/observability/lib/launchdarkly_flutter_observability.dart @@ -25,6 +25,7 @@ export 'src/options/session_replay_options.dart' show SessionReplayOptions, PrivacyOptions; export 'src/plugin/observability_config.dart' show DebugPrintSetting; export 'src/api/log_severity.dart' show LogSeverity; +export 'src/api/log_record.dart' show LDLogRecord, LDLogRecordProcessor; export 'src/api/span.dart' show Span; export 'src/api/span_kind.dart' show SpanKind; export 'src/api/span_status_code.dart' show SpanStatusCode; diff --git a/sdk/flutter/packages/observability/lib/src/api/log_record.dart b/sdk/flutter/packages/observability/lib/src/api/log_record.dart new file mode 100644 index 0000000000..125e57e215 --- /dev/null +++ b/sdk/flutter/packages/observability/lib/src/api/log_record.dart @@ -0,0 +1,44 @@ +import 'log_severity.dart'; + +/// A log on its way to export, as seen by an [LDLogRecordProcessor]. +/// +/// Every field is mutable: a processor's changes are what gets exported. +/// [attributes] already holds the global attributes from +/// `LDObserve.setAttributes`, beneath the attributes passed to the call. +final class LDLogRecord { + /// The log message. + String message; + + /// The log severity. + LogSeverity severity; + + /// The stack trace attached to the log, if any. + StackTrace? stackTrace; + + /// The log attributes as plain Dart values. Values that cannot be + /// represented as an attribute (such as nested maps) are dropped on export. + final Map attributes; + + /// Creates a log record. The SDK creates one per log; construct your own to + /// unit test a processor. + LDLogRecord({ + required this.message, + this.severity = LogSeverity.info, + this.stackTrace, + Map? attributes, + }) : attributes = {...?attributes}; +} + +/// Observes, and may modify, every log recorded through the SDK before it is +/// exported. Pass processors through `ObservabilityOptions.logProcessors`. +/// +/// Processors run in order on the thread that recorded the log, so keep them +/// fast. A processor that throws is skipped for that log; the log is still +/// exported. +abstract class LDLogRecordProcessor { + /// Const constructor for subclasses. + const LDLogRecordProcessor(); + + /// Called for each log before it is exported. + void onEmit(LDLogRecord record); +} 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..b921f1f461 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'; @@ -260,14 +261,33 @@ final class ObserveOtel { // Delegate to the platform-appropriate recorder: native log records on // mobile (stamped with `session.id`), span events on web. `null` before // the pipeline is initialized, in which case the log is dropped. - Otel.logRecorder?.recordLog( + final recorder = Otel.logRecorder; + if (recorder == null) { + return; + } + final log = Otel.logPipeline.process( message, severity: severity, stackTrace: stackTrace, attributes: attributes, ); + recorder.recordLog( + log.message, + severity: log.severity, + stackTrace: log.stackTrace, + attributes: log.attributes, + ); } + /// 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/options/observability_options.dart b/sdk/flutter/packages/observability/lib/src/options/observability_options.dart index 311ca85ee0..9aa6bf5f61 100644 --- a/sdk/flutter/packages/observability/lib/src/options/observability_options.dart +++ b/sdk/flutter/packages/observability/lib/src/options/observability_options.dart @@ -1,6 +1,9 @@ // Ported from // sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/ObservabilityOptions.cs. +import 'package:opentelemetry/sdk.dart' show SpanProcessor; + +import '../api/log_record.dart'; import '../instrumentation/click/ld_click.dart'; import '../plugin/observability_config.dart'; @@ -324,6 +327,26 @@ class ObservabilityOptions { /// crash reporting and `debugPrint` capture. final InstrumentationOptions instrumentation; + /// OpenTelemetry span processors run on every span recorded from Dart, + /// in order, before the SDK exports it. + /// + /// They run after the global attributes from `LDObserve.setAttributes` are + /// applied, so they see them, and changes made in `onStart` are exported. A + /// processor that throws is skipped and the span is still exported. + /// + /// On iOS and Android, spans the native SDK records itself (including the + /// `track`, `screen_view` and `click` spans) do not pass through them. + final List spanProcessors; + + /// Processors run on every log recorded from Dart (`LDObserve.recordLog`, + /// captured `debugPrint` and `print`), in order, before the SDK exports it. + /// + /// They run after the global attributes from `LDObserve.setAttributes` are + /// applied, so they see them, and their changes are exported. A processor + /// that throws is skipped and the log is still exported. Logs the native SDK + /// records itself do not pass through them. + final List logProcessors; + /// Creates observability options. Every argument is optional; the defaults /// enable all telemetry and export to LaunchDarkly. const ObservabilityOptions({ @@ -341,6 +364,8 @@ class ObservabilityOptions { this.metricsEnabled = true, this.analytics = const AnalyticsOptions(), this.instrumentation = const InstrumentationOptions(), + this.spanProcessors = const [], + this.logProcessors = const [], }) : otlpEndpoint = otlpEndpoint ?? defaultOtlpEndpoint, backendUrl = backendUrl ?? defaultBackendUrl; } @@ -365,5 +390,7 @@ extension ObservabilityOptionsCopy on ObservabilityOptions { metricsEnabled: metricsEnabled, analytics: analytics, instrumentation: instrumentation, + spanProcessors: spanProcessors, + logProcessors: logProcessors, ); } diff --git a/sdk/flutter/packages/observability/lib/src/otel/conversions.dart b/sdk/flutter/packages/observability/lib/src/otel/conversions.dart index 53c202b364..a9f413b0c6 100644 --- a/sdk/flutter/packages/observability/lib/src/otel/conversions.dart +++ b/sdk/flutter/packages/observability/lib/src/otel/conversions.dart @@ -103,6 +103,23 @@ Map attributesFromProperties( return attributes; } +/// The inverse of [attributesFromProperties]: plain Dart values for each +/// representable attribute in [attributes]. +/// +/// Not for export. +Map propertiesFromAttributes( + Map? attributes, +) { + final properties = {}; + attributes?.forEach((name, attribute) { + final otelAttribute = convertAttribute(name, attribute); + if (otelAttribute != null) { + properties[name] = otelAttribute.value; + } + }); + return properties; +} + /// Not for export. otel.SpanKind convertKind(SpanKind kind) { switch (kind) { 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..d79b3f37a5 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, ), 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..d78a3aa4ab --- /dev/null +++ b/sdk/flutter/packages/observability/lib/src/otel/global_attributes.dart @@ -0,0 +1,117 @@ +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:opentelemetry/api.dart' as otel; + +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; +} + +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/processors.dart b/sdk/flutter/packages/observability/lib/src/otel/processors.dart new file mode 100644 index 0000000000..3c7c09cdd4 --- /dev/null +++ b/sdk/flutter/packages/observability/lib/src/otel/processors.dart @@ -0,0 +1,198 @@ +import 'dart:developer' as developer; + +import 'package:opentelemetry/api.dart' as otel; +import 'package:opentelemetry/sdk.dart' + show ReadOnlySpan, ReadWriteSpan, SpanProcessor; + +import '../api/attribute.dart'; +import '../api/log_record.dart'; +import '../api/log_severity.dart'; +import 'conversions.dart'; +import 'global_attributes.dart'; + +const _logName = 'LDObserve'; + +/// The span processors wired into the tracer provider, in order: global +/// attributes, then the app's [userProcessors] (each guarded so a throw cannot +/// break the pipeline), then the SDK's [exportProcessors]. +/// +/// Not for export. +List buildSpanProcessors( + List userProcessors, + List exportProcessors, +) => [ + GlobalAttributesSpanProcessor(), + for (final processor in userProcessors) GuardedSpanProcessor(processor), + ...exportProcessors, +]; + +/// 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. +/// +/// 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() {} +} + +/// Runs an app-supplied [SpanProcessor], logging and swallowing anything it +/// throws so the SDK's own processors still run and export. +/// +/// Not for export. +final class GuardedSpanProcessor implements SpanProcessor { + /// Wraps [_delegate]. + GuardedSpanProcessor(this._delegate); + + final SpanProcessor _delegate; + final _reporter = _FailureReporter(); + + @override + void onStart(ReadWriteSpan span, otel.Context parentContext) { + try { + _delegate.onStart(span, parentContext); + } catch (error, stackTrace) { + _reporter.report('span processor', _delegate, error, stackTrace); + } + } + + @override + void onEnd(ReadOnlySpan span) { + try { + _delegate.onEnd(span); + } catch (error, stackTrace) { + _reporter.report('span processor', _delegate, error, stackTrace); + } + } + + @override + void shutdown() { + try { + _delegate.shutdown(); + } catch (error, stackTrace) { + _reporter.report('span processor', _delegate, error, stackTrace); + } + } + + @override + void forceFlush() { + try { + _delegate.forceFlush(); + } catch (error, stackTrace) { + _reporter.report('span processor', _delegate, error, stackTrace); + } + } +} + +/// A log ready for its recorder, after global attributes and the app's log +/// processors have been applied. +/// +/// Not for export. +typedef ProcessedLog = ({ + String message, + LogSeverity severity, + StackTrace? stackTrace, + Map? attributes, +}); + +/// Runs logs through global attributes and then the app's log processors, in +/// order, each guarded so a throw cannot stop the log from being exported. +/// +/// Not for export. +final class LogPipeline { + /// Creates a pipeline running [processors] in order. + LogPipeline(List processors) + : _processors = List.unmodifiable(processors), + _reporters = [for (final _ in processors) _FailureReporter()]; + + final List _processors; + final List<_FailureReporter> _reporters; + + /// Applies global attributes, then each processor, to a log. + ProcessedLog process( + String message, { + required LogSeverity severity, + StackTrace? stackTrace, + Map? attributes, + }) { + final withGlobals = GlobalAttributes.fillAttributes(attributes); + if (_processors.isEmpty) { + return ( + message: message, + severity: severity, + stackTrace: stackTrace, + attributes: withGlobals, + ); + } + + final record = LDLogRecord( + message: message, + severity: severity, + stackTrace: stackTrace, + attributes: propertiesFromAttributes(withGlobals), + ); + for (var i = 0; i < _processors.length; i++) { + try { + _processors[i].onEmit(record); + } catch (error, stackTrace) { + _reporters[i].report( + 'log processor', + _processors[i], + error, + stackTrace, + ); + } + } + return ( + message: record.message, + severity: record.severity, + stackTrace: record.stackTrace, + attributes: attributesFromProperties(record.attributes), + ); + } +} + +/// Logs the first failure of one processor; later ones would only repeat it +/// for every span or log. +final class _FailureReporter { + bool _reported = false; + + void report( + String kind, + Object processor, + Object error, + StackTrace stackTrace, + ) { + if (_reported) { + return; + } + _reported = true; + developer.log( + 'A custom $kind (${processor.runtimeType}) threw; it is skipped and ' + 'telemetry is still exported. Further failures are not logged.', + name: _logName, + error: error, + stackTrace: stackTrace, + ); + } +} diff --git a/sdk/flutter/packages/observability/lib/src/otel/setup.dart b/sdk/flutter/packages/observability/lib/src/otel/setup.dart index 108c6ec463..c7b021494d 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/processors.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'; @@ -23,6 +24,12 @@ class Otel { /// the pipeline is initialized. static LogRecorder? get logRecorder => _logRecorder; + static LogPipeline _logPipeline = LogPipeline(const []); + + /// Applies global attributes and the app's log processors to each log before + /// it reaches [logRecorder]. + static LogPipeline get logPipeline => _logPipeline; + static TrackRecorder? _trackRecorder; /// The platform-appropriate track recorder, set during [setup]. `null` before @@ -100,6 +107,7 @@ class Otel { if (config.enabled) { _setupTracing(sdkKey, config, exporters); + _logPipeline = LogPipeline(config.logProcessors); _logRecorder = exporters.createLogRecorder(config); } @@ -123,7 +131,10 @@ class Otel { ObservabilityExporters exporters, ) { final tracerProvider = TracerProviderBase( - processors: exporters.createSpanProcessors(config), + processors: buildSpanProcessors( + config.spanProcessors, + exporters.createSpanProcessors(config), + ), resource: Resource( resourceAttributes(sdkKey, config, symbolsId: readSymbolsId()), ), @@ -175,6 +186,7 @@ class Otel { } _tracerProviders.clear(); _logRecorder = null; + _logPipeline = LogPipeline(const []); _trackRecorder = null; _identifyRecorder = null; _clickRecorder = null; diff --git a/sdk/flutter/packages/observability/lib/src/plugin/observability_config.dart b/sdk/flutter/packages/observability/lib/src/plugin/observability_config.dart index 32f2a88069..17987435c7 100644 --- a/sdk/flutter/packages/observability/lib/src/plugin/observability_config.dart +++ b/sdk/flutter/packages/observability/lib/src/plugin/observability_config.dart @@ -1,6 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:launchdarkly_flutter_client_sdk/launchdarkly_flutter_client_sdk.dart'; +import 'package:opentelemetry/sdk.dart' show SpanProcessor; +import '../api/log_record.dart'; import '../options/observability_options.dart'; const _defaultOtlpEndpoint = @@ -143,6 +145,12 @@ final class ObservabilityConfig { /// while on mobile the native SDK applies its own gating. Defaults to `true`. final bool tapsEnabled; + /// The app's span processors, from `ObservabilityOptions.spanProcessors`. + final List spanProcessors; + + /// The app's log processors, from `ObservabilityOptions.logProcessors`. + final List logProcessors; + /// Creates a configuration; prefer [configFromOptions] or /// [configWithDefaults], which fill in the default endpoints. ObservabilityConfig({ @@ -157,6 +165,8 @@ final class ObservabilityConfig { this.trackEventsEnabled = true, this.screenViewsEnabled = true, this.tapsEnabled = true, + this.spanProcessors = const [], + this.logProcessors = const [], }); } @@ -198,5 +208,7 @@ ObservabilityConfig configFromOptions(ObservabilityOptions options) { trackEventsEnabled: options.analytics.trackEvents, screenViewsEnabled: options.analytics.views, tapsEnabled: options.analytics.taps, + spanProcessors: List.unmodifiable(options.spanProcessors), + logProcessors: List.unmodifiable(options.logProcessors), ); } 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..f7dad72132 --- /dev/null +++ b/sdk/flutter/packages/observability/test/global_attributes_pipeline_test.dart @@ -0,0 +1,234 @@ +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 and custom processors 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(); + final spanProcessor = _MutatingSpanProcessor(); + + setUpAll(() { + native.install(); + GlobalAttributes.resetForTesting(); + // Before setup: globals set ahead of init must still apply. + LDObserve.setAttributes({'tenant': 'acme'}); + Otel.setup( + 'mobile-key', + configFromOptions( + ObservabilityOptions( + spanProcessors: [_ThrowingSpanProcessor(), spanProcessor], + logProcessors: [ + _CallbackLogProcessor((_) => throw StateError('boom')), + _CallbackLogProcessor((record) { + record.attributes['log.processor.saw_tenant'] = + record.attributes['tenant'] ?? 'missing'; + }), + ], + ), + ), + ); + // Buffered by the batch processor until a test flushes it. + LDObserve.startSpan('early').end(); + }); + + setUp(() { + native.calls.clear(); + spanProcessor.seenTenant.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'); + }); + + test('reach user processors after globals, and their changes are exported ' + 'even when an earlier processor throws', () async { + LDObserve.startSpan('processed').end(); + + expect(spanProcessor.seenTenant, ['acme']); + final attributes = (await exportedSpan('processed')).attributes!; + expect(attributes['processor.mutated'], true); + expect(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'); + }); + + test('reach user processors after globals, and their changes are exported ' + 'even when an earlier processor throws', () async { + LDObserve.recordLog('processed'); + + final log = await recordedLog(); + expect(log.message, 'processed'); + expect(log.attributes?['log.processor.saw_tenant'], 'acme'); + }); + }); + + 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'}); + }); + }); +} + +/// Records the `tenant` it sees at start and marks the span. +final class _MutatingSpanProcessor implements sdk.SpanProcessor { + final List seenTenant = []; + + @override + void onStart(sdk.ReadWriteSpan span, otel.Context parentContext) { + if (span.name != 'processed') { + return; + } + seenTenant.add(span.attributes.get('tenant')); + span.setAttribute(otel.Attribute.fromBoolean('processor.mutated', true)); + } + + @override + void onEnd(sdk.ReadOnlySpan span) {} + + @override + void shutdown() {} + + @override + void forceFlush() {} +} + +final class _ThrowingSpanProcessor implements sdk.SpanProcessor { + @override + void onStart(sdk.ReadWriteSpan span, otel.Context parentContext) => + throw StateError('onStart'); + + @override + void onEnd(sdk.ReadOnlySpan span) => throw StateError('onEnd'); + + @override + void shutdown() => throw StateError('shutdown'); + + @override + void forceFlush() => throw StateError('forceFlush'); +} + +final class _CallbackLogProcessor extends LDLogRecordProcessor { + const _CallbackLogProcessor(this._onEmit); + + final void Function(LDLogRecord record) _onEmit; + + @override + void onEmit(LDLogRecord record) => _onEmit(record); +} 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..a3d4d68957 --- /dev/null +++ b/sdk/flutter/packages/observability/test/global_attributes_test.dart @@ -0,0 +1,169 @@ +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'; +import 'package:launchdarkly_flutter_observability/src/otel/processors.dart'; + +/// The global attribute store and the log pipeline, 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); + }); + }); + + group('log pipeline', () { + test('applies globals beneath the call attributes', () { + LDObserve.setAttributes({'k': 'global', 'g': 'global'}); + + final log = LogPipeline(const []).process( + 'hi', + severity: LogSeverity.info, + attributes: {'k': StringAttribute('call')}, + ); + + expect(_values(log.attributes), {'k': 'call', 'g': 'global'}); + }); + + test('runs processors in order and exports their changes', () { + LDObserve.setAttributes({'g': 'global'}); + final seen = >[]; + + final log = + LogPipeline([ + _CallbackLogProcessor((record) { + seen.add(Map.of(record.attributes)); + record.attributes['first'] = true; + record.message = 'redacted'; + }), + _CallbackLogProcessor((record) { + seen.add(Map.of(record.attributes)); + record.severity = LogSeverity.warn; + record.attributes.remove('call'); + }), + ]).process( + 'secret', + severity: LogSeverity.info, + attributes: {'call': IntAttribute(1)}, + ); + + expect(seen, [ + {'g': 'global', 'call': 1}, + {'g': 'global', 'call': 1, 'first': true}, + ]); + expect(log.message, 'redacted'); + expect(log.severity, LogSeverity.warn); + expect(_values(log.attributes), {'g': 'global', 'first': true}); + }); + + test('a throwing processor is skipped and the log still exported', () { + final log = LogPipeline([ + _CallbackLogProcessor((record) { + record.attributes['before'] = 1; + throw StateError('boom'); + }), + _CallbackLogProcessor((record) => record.attributes['after'] = 2), + ]).process('hi', severity: LogSeverity.error); + + expect(log.message, 'hi'); + expect(_values(log.attributes), {'before': 1, 'after': 2}); + }); + }); +} + +Map _values(Map? attributes) => { + for (final entry in {...?attributes}.entries) + entry.key: switch (entry.value) { + IntAttribute(:final value) => value, + DoubleAttribute(:final value) => value, + BooleanAttribute(:final value) => value, + StringAttribute(:final value) => value, + StringListAttribute(:final value) => value, + DoubleListAttribute(:final value) => value, + IntListAttribute(:final value) => value, + BooleanListAttribute(:final value) => value, + InvalidAttribute() => null, + }, +}; + +final class _CallbackLogProcessor extends LDLogRecordProcessor { + const _CallbackLogProcessor(this._onEmit); + + final void Function(LDLogRecord record) _onEmit; + + @override + void onEmit(LDLogRecord record) => _onEmit(record); +} diff --git a/sdk/flutter/packages/observability/test/public_api_test.dart b/sdk/flutter/packages/observability/test/public_api_test.dart index a9d71edddd..25a590b9d9 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,11 +92,23 @@ void main() { trackClick, recordException, recordLog, + setAttributes, + removeAttributes, zoneSpecification, shutdown, ], everyElement(isNotNull)); }); + test('LDLogRecordProcessor and LDLogRecord are usable from the barrel', () { + final record = LDLogRecord(message: 'm', attributes: {'k': 1}); + final void Function(LDLogRecord) onEmit = + const _NoopLogProcessor().onEmit; + onEmit(record); + expect(record.severity, LogSeverity.info); + expect(record.stackTrace, isNull); + expect(record.attributes, {'k': 1}); + }); + test('Span methods match the 1.0 API', () { void checkSpan(Span span) { final void Function() end = span.end; @@ -186,6 +202,8 @@ void main() { expect(options.traces.includeErrors, isTrue); expect(options.traces.includeSpans, isTrue); expect(options.metricsEnabled, isTrue); + expect(options.spanProcessors, isEmpty); + expect(options.logProcessors, isEmpty); }); test('InstrumentationOptions', () { @@ -265,3 +283,10 @@ void main() { }); }); } + +final class _NoopLogProcessor extends LDLogRecordProcessor { + const _NoopLogProcessor(); + + @override + void onEmit(LDLogRecord record) {} +} diff --git a/sdk/flutter/packages/observability/test/web_exporters_test.dart b/sdk/flutter/packages/observability/test/web_exporters_test.dart index d1ef93576f..b05e785111 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/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/processors.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: buildSpanProcessors(const [], [processor]), + ), ); }); setUp(processor.ended.clear); + tearDown(GlobalAttributes.resetForTesting); void recordAll(ObservabilityConfig config) { exporters.createTrackRecorder(config).track('purchase'); @@ -83,6 +89,21 @@ 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'); + }); }); group('resource attributes', () { From 5a723ce2e7df7715ba3f502f10cfbe3802f6668d Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 28 Oct 2026 16:49:23 -0700 Subject: [PATCH 2/4] refactor(flutter): drop custom span/log processor options Co-authored-by: Cursor --- sdk/flutter/packages/observability/README.md | 49 ----- .../launchdarkly_flutter_observability.dart | 1 - .../observability/lib/src/api/log_record.dart | 44 ---- .../observability/lib/src/observe_otel.dart | 14 +- .../src/options/observability_options.dart | 27 --- .../lib/src/otel/conversions.dart | 17 -- .../lib/src/otel/global_attributes.dart | 33 +++ .../lib/src/otel/processors.dart | 198 ------------------ .../observability/lib/src/otel/setup.dart | 18 +- .../lib/src/plugin/observability_config.dart | 12 -- .../test/global_attributes_pipeline_test.dart | 86 +------- .../test/global_attributes_test.dart | 85 +------- .../observability/test/public_api_test.dart | 19 -- .../test/web_exporters_test.dart | 3 +- 14 files changed, 44 insertions(+), 562 deletions(-) delete mode 100644 sdk/flutter/packages/observability/lib/src/api/log_record.dart delete mode 100644 sdk/flutter/packages/observability/lib/src/otel/processors.dart diff --git a/sdk/flutter/packages/observability/README.md b/sdk/flutter/packages/observability/README.md index 7fe9cb453c..a40c93d656 100644 --- a/sdk/flutter/packages/observability/README.md +++ b/sdk/flutter/packages/observability/README.md @@ -511,55 +511,6 @@ LDObserve.removeAttributes(['tenant.id']); 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. -### Custom span and log processors - -Pass `spanProcessors` and `logProcessors` to `ObservabilityOptions` to inspect or modify telemetry before it is exported — for example to add attributes computed at record time or to redact values. They run in order, after global attributes are applied (so they see them) and before the SDK exports the data, so their changes are what gets sent. A processor that throws is skipped for that span or log, and the data is still exported. - -`spanProcessors` takes OpenTelemetry `SpanProcessor`s from the [`opentelemetry`](https://pub.dev/packages/opentelemetry) package; add it to your `pubspec.yaml` (`opentelemetry: ^0.18.11`) to implement one. Attributes set in `onStart` are exported. `logProcessors` takes `LDLogRecordProcessor`s: the `opentelemetry` package has no logs SDK, so the Flutter SDK defines its own log record, `LDLogRecord`, whose `message`, `severity`, `stackTrace`, and `attributes` can all be changed. - -```dart -import 'package:launchdarkly_flutter_observability/launchdarkly_flutter_observability.dart'; -import 'package:opentelemetry/api.dart' as otel; -import 'package:opentelemetry/sdk.dart' as otel_sdk; - -class ScreenSpanProcessor implements otel_sdk.SpanProcessor { - @override - void onStart(otel_sdk.ReadWriteSpan span, otel.Context parentContext) { - span.setAttribute(otel.Attribute.fromString('app.screen', currentScreen)); - } - - @override - void onEnd(otel_sdk.ReadOnlySpan span) {} - - @override - void shutdown() {} - - @override - void forceFlush() {} -} - -class RedactEmailProcessor extends LDLogRecordProcessor { - @override - void onEmit(LDLogRecord record) { - record.message = record.message.replaceAll( - RegExp(r'[\w.+-]+@[\w-]+\.[\w.]+'), - '', - ); - record.attributes.remove('user.email'); - } -} - -LDObserve.initStandalone( - mobileKey, - observability: ObservabilityOptions( - spanProcessors: [ScreenSpanProcessor()], - logProcessors: [RedactEmailProcessor()], - ), -); -``` - -Processors see spans and logs recorded from Dart: `LDObserve` calls, flag evaluations, errors, Dart lifecycle spans, and captured `debugPrint`/`print` output, on every platform. On iOS and Android the `track`, `screen_view`, and `click` spans are created by the native SDK and do not pass through `spanProcessors` (they still carry global attributes), and neither do the native SDK's own spans and logs. - ## 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/launchdarkly_flutter_observability.dart b/sdk/flutter/packages/observability/lib/launchdarkly_flutter_observability.dart index d7ccfbc27d..8b77007692 100644 --- a/sdk/flutter/packages/observability/lib/launchdarkly_flutter_observability.dart +++ b/sdk/flutter/packages/observability/lib/launchdarkly_flutter_observability.dart @@ -25,7 +25,6 @@ export 'src/options/session_replay_options.dart' show SessionReplayOptions, PrivacyOptions; export 'src/plugin/observability_config.dart' show DebugPrintSetting; export 'src/api/log_severity.dart' show LogSeverity; -export 'src/api/log_record.dart' show LDLogRecord, LDLogRecordProcessor; export 'src/api/span.dart' show Span; export 'src/api/span_kind.dart' show SpanKind; export 'src/api/span_status_code.dart' show SpanStatusCode; diff --git a/sdk/flutter/packages/observability/lib/src/api/log_record.dart b/sdk/flutter/packages/observability/lib/src/api/log_record.dart deleted file mode 100644 index 125e57e215..0000000000 --- a/sdk/flutter/packages/observability/lib/src/api/log_record.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'log_severity.dart'; - -/// A log on its way to export, as seen by an [LDLogRecordProcessor]. -/// -/// Every field is mutable: a processor's changes are what gets exported. -/// [attributes] already holds the global attributes from -/// `LDObserve.setAttributes`, beneath the attributes passed to the call. -final class LDLogRecord { - /// The log message. - String message; - - /// The log severity. - LogSeverity severity; - - /// The stack trace attached to the log, if any. - StackTrace? stackTrace; - - /// The log attributes as plain Dart values. Values that cannot be - /// represented as an attribute (such as nested maps) are dropped on export. - final Map attributes; - - /// Creates a log record. The SDK creates one per log; construct your own to - /// unit test a processor. - LDLogRecord({ - required this.message, - this.severity = LogSeverity.info, - this.stackTrace, - Map? attributes, - }) : attributes = {...?attributes}; -} - -/// Observes, and may modify, every log recorded through the SDK before it is -/// exported. Pass processors through `ObservabilityOptions.logProcessors`. -/// -/// Processors run in order on the thread that recorded the log, so keep them -/// fast. A processor that throws is skipped for that log; the log is still -/// exported. -abstract class LDLogRecordProcessor { - /// Const constructor for subclasses. - const LDLogRecordProcessor(); - - /// Called for each log before it is exported. - void onEmit(LDLogRecord record); -} diff --git a/sdk/flutter/packages/observability/lib/src/observe_otel.dart b/sdk/flutter/packages/observability/lib/src/observe_otel.dart index b921f1f461..452195618f 100644 --- a/sdk/flutter/packages/observability/lib/src/observe_otel.dart +++ b/sdk/flutter/packages/observability/lib/src/observe_otel.dart @@ -261,21 +261,11 @@ final class ObserveOtel { // Delegate to the platform-appropriate recorder: native log records on // mobile (stamped with `session.id`), span events on web. `null` before // the pipeline is initialized, in which case the log is dropped. - final recorder = Otel.logRecorder; - if (recorder == null) { - return; - } - final log = Otel.logPipeline.process( + Otel.logRecorder?.recordLog( message, severity: severity, stackTrace: stackTrace, - attributes: attributes, - ); - recorder.recordLog( - log.message, - severity: log.severity, - stackTrace: log.stackTrace, - attributes: log.attributes, + attributes: GlobalAttributes.fillAttributes(attributes), ); } diff --git a/sdk/flutter/packages/observability/lib/src/options/observability_options.dart b/sdk/flutter/packages/observability/lib/src/options/observability_options.dart index 9aa6bf5f61..311ca85ee0 100644 --- a/sdk/flutter/packages/observability/lib/src/options/observability_options.dart +++ b/sdk/flutter/packages/observability/lib/src/options/observability_options.dart @@ -1,9 +1,6 @@ // Ported from // sdk/@launchdarkly/mobile-dotnet/observability/observe/plugin/ObservabilityOptions.cs. -import 'package:opentelemetry/sdk.dart' show SpanProcessor; - -import '../api/log_record.dart'; import '../instrumentation/click/ld_click.dart'; import '../plugin/observability_config.dart'; @@ -327,26 +324,6 @@ class ObservabilityOptions { /// crash reporting and `debugPrint` capture. final InstrumentationOptions instrumentation; - /// OpenTelemetry span processors run on every span recorded from Dart, - /// in order, before the SDK exports it. - /// - /// They run after the global attributes from `LDObserve.setAttributes` are - /// applied, so they see them, and changes made in `onStart` are exported. A - /// processor that throws is skipped and the span is still exported. - /// - /// On iOS and Android, spans the native SDK records itself (including the - /// `track`, `screen_view` and `click` spans) do not pass through them. - final List spanProcessors; - - /// Processors run on every log recorded from Dart (`LDObserve.recordLog`, - /// captured `debugPrint` and `print`), in order, before the SDK exports it. - /// - /// They run after the global attributes from `LDObserve.setAttributes` are - /// applied, so they see them, and their changes are exported. A processor - /// that throws is skipped and the log is still exported. Logs the native SDK - /// records itself do not pass through them. - final List logProcessors; - /// Creates observability options. Every argument is optional; the defaults /// enable all telemetry and export to LaunchDarkly. const ObservabilityOptions({ @@ -364,8 +341,6 @@ class ObservabilityOptions { this.metricsEnabled = true, this.analytics = const AnalyticsOptions(), this.instrumentation = const InstrumentationOptions(), - this.spanProcessors = const [], - this.logProcessors = const [], }) : otlpEndpoint = otlpEndpoint ?? defaultOtlpEndpoint, backendUrl = backendUrl ?? defaultBackendUrl; } @@ -390,7 +365,5 @@ extension ObservabilityOptionsCopy on ObservabilityOptions { metricsEnabled: metricsEnabled, analytics: analytics, instrumentation: instrumentation, - spanProcessors: spanProcessors, - logProcessors: logProcessors, ); } diff --git a/sdk/flutter/packages/observability/lib/src/otel/conversions.dart b/sdk/flutter/packages/observability/lib/src/otel/conversions.dart index a9f413b0c6..53c202b364 100644 --- a/sdk/flutter/packages/observability/lib/src/otel/conversions.dart +++ b/sdk/flutter/packages/observability/lib/src/otel/conversions.dart @@ -103,23 +103,6 @@ Map attributesFromProperties( return attributes; } -/// The inverse of [attributesFromProperties]: plain Dart values for each -/// representable attribute in [attributes]. -/// -/// Not for export. -Map propertiesFromAttributes( - Map? attributes, -) { - final properties = {}; - attributes?.forEach((name, attribute) { - final otelAttribute = convertAttribute(name, attribute); - if (otelAttribute != null) { - properties[name] = otelAttribute.value; - } - }); - return properties; -} - /// Not for export. otel.SpanKind convertKind(SpanKind kind) { switch (kind) { diff --git a/sdk/flutter/packages/observability/lib/src/otel/global_attributes.dart b/sdk/flutter/packages/observability/lib/src/otel/global_attributes.dart index d78a3aa4ab..b9e360d336 100644 --- a/sdk/flutter/packages/observability/lib/src/otel/global_attributes.dart +++ b/sdk/flutter/packages/observability/lib/src/otel/global_attributes.dart @@ -1,5 +1,7 @@ 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'; @@ -95,6 +97,37 @@ final class GlobalAttributes { 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( diff --git a/sdk/flutter/packages/observability/lib/src/otel/processors.dart b/sdk/flutter/packages/observability/lib/src/otel/processors.dart deleted file mode 100644 index 3c7c09cdd4..0000000000 --- a/sdk/flutter/packages/observability/lib/src/otel/processors.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'dart:developer' as developer; - -import 'package:opentelemetry/api.dart' as otel; -import 'package:opentelemetry/sdk.dart' - show ReadOnlySpan, ReadWriteSpan, SpanProcessor; - -import '../api/attribute.dart'; -import '../api/log_record.dart'; -import '../api/log_severity.dart'; -import 'conversions.dart'; -import 'global_attributes.dart'; - -const _logName = 'LDObserve'; - -/// The span processors wired into the tracer provider, in order: global -/// attributes, then the app's [userProcessors] (each guarded so a throw cannot -/// break the pipeline), then the SDK's [exportProcessors]. -/// -/// Not for export. -List buildSpanProcessors( - List userProcessors, - List exportProcessors, -) => [ - GlobalAttributesSpanProcessor(), - for (final processor in userProcessors) GuardedSpanProcessor(processor), - ...exportProcessors, -]; - -/// 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. -/// -/// 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() {} -} - -/// Runs an app-supplied [SpanProcessor], logging and swallowing anything it -/// throws so the SDK's own processors still run and export. -/// -/// Not for export. -final class GuardedSpanProcessor implements SpanProcessor { - /// Wraps [_delegate]. - GuardedSpanProcessor(this._delegate); - - final SpanProcessor _delegate; - final _reporter = _FailureReporter(); - - @override - void onStart(ReadWriteSpan span, otel.Context parentContext) { - try { - _delegate.onStart(span, parentContext); - } catch (error, stackTrace) { - _reporter.report('span processor', _delegate, error, stackTrace); - } - } - - @override - void onEnd(ReadOnlySpan span) { - try { - _delegate.onEnd(span); - } catch (error, stackTrace) { - _reporter.report('span processor', _delegate, error, stackTrace); - } - } - - @override - void shutdown() { - try { - _delegate.shutdown(); - } catch (error, stackTrace) { - _reporter.report('span processor', _delegate, error, stackTrace); - } - } - - @override - void forceFlush() { - try { - _delegate.forceFlush(); - } catch (error, stackTrace) { - _reporter.report('span processor', _delegate, error, stackTrace); - } - } -} - -/// A log ready for its recorder, after global attributes and the app's log -/// processors have been applied. -/// -/// Not for export. -typedef ProcessedLog = ({ - String message, - LogSeverity severity, - StackTrace? stackTrace, - Map? attributes, -}); - -/// Runs logs through global attributes and then the app's log processors, in -/// order, each guarded so a throw cannot stop the log from being exported. -/// -/// Not for export. -final class LogPipeline { - /// Creates a pipeline running [processors] in order. - LogPipeline(List processors) - : _processors = List.unmodifiable(processors), - _reporters = [for (final _ in processors) _FailureReporter()]; - - final List _processors; - final List<_FailureReporter> _reporters; - - /// Applies global attributes, then each processor, to a log. - ProcessedLog process( - String message, { - required LogSeverity severity, - StackTrace? stackTrace, - Map? attributes, - }) { - final withGlobals = GlobalAttributes.fillAttributes(attributes); - if (_processors.isEmpty) { - return ( - message: message, - severity: severity, - stackTrace: stackTrace, - attributes: withGlobals, - ); - } - - final record = LDLogRecord( - message: message, - severity: severity, - stackTrace: stackTrace, - attributes: propertiesFromAttributes(withGlobals), - ); - for (var i = 0; i < _processors.length; i++) { - try { - _processors[i].onEmit(record); - } catch (error, stackTrace) { - _reporters[i].report( - 'log processor', - _processors[i], - error, - stackTrace, - ); - } - } - return ( - message: record.message, - severity: record.severity, - stackTrace: record.stackTrace, - attributes: attributesFromProperties(record.attributes), - ); - } -} - -/// Logs the first failure of one processor; later ones would only repeat it -/// for every span or log. -final class _FailureReporter { - bool _reported = false; - - void report( - String kind, - Object processor, - Object error, - StackTrace stackTrace, - ) { - if (_reported) { - return; - } - _reported = true; - developer.log( - 'A custom $kind (${processor.runtimeType}) threw; it is skipped and ' - 'telemetry is still exported. Further failures are not logged.', - name: _logName, - error: error, - stackTrace: stackTrace, - ); - } -} diff --git a/sdk/flutter/packages/observability/lib/src/otel/setup.dart b/sdk/flutter/packages/observability/lib/src/otel/setup.dart index c7b021494d..8272ff9f36 100644 --- a/sdk/flutter/packages/observability/lib/src/otel/setup.dart +++ b/sdk/flutter/packages/observability/lib/src/otel/setup.dart @@ -3,7 +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/processors.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'; @@ -24,12 +24,6 @@ class Otel { /// the pipeline is initialized. static LogRecorder? get logRecorder => _logRecorder; - static LogPipeline _logPipeline = LogPipeline(const []); - - /// Applies global attributes and the app's log processors to each log before - /// it reaches [logRecorder]. - static LogPipeline get logPipeline => _logPipeline; - static TrackRecorder? _trackRecorder; /// The platform-appropriate track recorder, set during [setup]. `null` before @@ -107,7 +101,6 @@ class Otel { if (config.enabled) { _setupTracing(sdkKey, config, exporters); - _logPipeline = LogPipeline(config.logProcessors); _logRecorder = exporters.createLogRecorder(config); } @@ -131,10 +124,10 @@ class Otel { ObservabilityExporters exporters, ) { final tracerProvider = TracerProviderBase( - processors: buildSpanProcessors( - config.spanProcessors, - exporters.createSpanProcessors(config), - ), + processors: [ + GlobalAttributesSpanProcessor(), + ...exporters.createSpanProcessors(config), + ], resource: Resource( resourceAttributes(sdkKey, config, symbolsId: readSymbolsId()), ), @@ -186,7 +179,6 @@ class Otel { } _tracerProviders.clear(); _logRecorder = null; - _logPipeline = LogPipeline(const []); _trackRecorder = null; _identifyRecorder = null; _clickRecorder = null; diff --git a/sdk/flutter/packages/observability/lib/src/plugin/observability_config.dart b/sdk/flutter/packages/observability/lib/src/plugin/observability_config.dart index 17987435c7..32f2a88069 100644 --- a/sdk/flutter/packages/observability/lib/src/plugin/observability_config.dart +++ b/sdk/flutter/packages/observability/lib/src/plugin/observability_config.dart @@ -1,8 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:launchdarkly_flutter_client_sdk/launchdarkly_flutter_client_sdk.dart'; -import 'package:opentelemetry/sdk.dart' show SpanProcessor; -import '../api/log_record.dart'; import '../options/observability_options.dart'; const _defaultOtlpEndpoint = @@ -145,12 +143,6 @@ final class ObservabilityConfig { /// while on mobile the native SDK applies its own gating. Defaults to `true`. final bool tapsEnabled; - /// The app's span processors, from `ObservabilityOptions.spanProcessors`. - final List spanProcessors; - - /// The app's log processors, from `ObservabilityOptions.logProcessors`. - final List logProcessors; - /// Creates a configuration; prefer [configFromOptions] or /// [configWithDefaults], which fill in the default endpoints. ObservabilityConfig({ @@ -165,8 +157,6 @@ final class ObservabilityConfig { this.trackEventsEnabled = true, this.screenViewsEnabled = true, this.tapsEnabled = true, - this.spanProcessors = const [], - this.logProcessors = const [], }); } @@ -208,7 +198,5 @@ ObservabilityConfig configFromOptions(ObservabilityOptions options) { trackEventsEnabled: options.analytics.trackEvents, screenViewsEnabled: options.analytics.views, tapsEnabled: options.analytics.taps, - spanProcessors: List.unmodifiable(options.spanProcessors), - logProcessors: List.unmodifiable(options.logProcessors), ); } diff --git a/sdk/flutter/packages/observability/test/global_attributes_pipeline_test.dart b/sdk/flutter/packages/observability/test/global_attributes_pipeline_test.dart index f7dad72132..d99824ecd8 100644 --- a/sdk/flutter/packages/observability/test/global_attributes_pipeline_test.dart +++ b/sdk/flutter/packages/observability/test/global_attributes_pipeline_test.dart @@ -10,42 +10,26 @@ import 'package:opentelemetry/sdk.dart' as sdk; import 'support/native_api_mock.dart'; -/// Global attributes and custom processors through the real pipeline, down to +/// 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(); - final spanProcessor = _MutatingSpanProcessor(); setUpAll(() { native.install(); GlobalAttributes.resetForTesting(); // Before setup: globals set ahead of init must still apply. LDObserve.setAttributes({'tenant': 'acme'}); - Otel.setup( - 'mobile-key', - configFromOptions( - ObservabilityOptions( - spanProcessors: [_ThrowingSpanProcessor(), spanProcessor], - logProcessors: [ - _CallbackLogProcessor((_) => throw StateError('boom')), - _CallbackLogProcessor((record) { - record.attributes['log.processor.saw_tenant'] = - record.attributes['tenant'] ?? 'missing'; - }), - ], - ), - ), - ); + Otel.setup('mobile-key', configWithDefaults()); // Buffered by the batch processor until a test flushes it. LDObserve.startSpan('early').end(); }); setUp(() { native.calls.clear(); - spanProcessor.seenTenant.clear(); GlobalAttributes.resetForTesting(); LDObserve.setAttributes({'tenant': 'acme'}); }); @@ -118,16 +102,6 @@ void main() { ); expect(span.attributes?['tenant'], 'acme'); }); - - test('reach user processors after globals, and their changes are exported ' - 'even when an earlier processor throws', () async { - LDObserve.startSpan('processed').end(); - - expect(spanProcessor.seenTenant, ['acme']); - final attributes = (await exportedSpan('processed')).attributes!; - expect(attributes['processor.mutated'], true); - expect(attributes['tenant'], 'acme'); - }); }); group('logs', () { @@ -144,15 +118,6 @@ void main() { expect(attributes['tenant'], 'acme'); expect(attributes['tier'], 'call'); }); - - test('reach user processors after globals, and their changes are exported ' - 'even when an earlier processor throws', () async { - LDObserve.recordLog('processed'); - - final log = await recordedLog(); - expect(log.message, 'processed'); - expect(log.attributes?['log.processor.saw_tenant'], 'acme'); - }); }); group('events forwarded to native', () { @@ -185,50 +150,3 @@ void main() { }); }); } - -/// Records the `tenant` it sees at start and marks the span. -final class _MutatingSpanProcessor implements sdk.SpanProcessor { - final List seenTenant = []; - - @override - void onStart(sdk.ReadWriteSpan span, otel.Context parentContext) { - if (span.name != 'processed') { - return; - } - seenTenant.add(span.attributes.get('tenant')); - span.setAttribute(otel.Attribute.fromBoolean('processor.mutated', true)); - } - - @override - void onEnd(sdk.ReadOnlySpan span) {} - - @override - void shutdown() {} - - @override - void forceFlush() {} -} - -final class _ThrowingSpanProcessor implements sdk.SpanProcessor { - @override - void onStart(sdk.ReadWriteSpan span, otel.Context parentContext) => - throw StateError('onStart'); - - @override - void onEnd(sdk.ReadOnlySpan span) => throw StateError('onEnd'); - - @override - void shutdown() => throw StateError('shutdown'); - - @override - void forceFlush() => throw StateError('forceFlush'); -} - -final class _CallbackLogProcessor extends LDLogRecordProcessor { - const _CallbackLogProcessor(this._onEmit); - - final void Function(LDLogRecord record) _onEmit; - - @override - void onEmit(LDLogRecord record) => _onEmit(record); -} diff --git a/sdk/flutter/packages/observability/test/global_attributes_test.dart b/sdk/flutter/packages/observability/test/global_attributes_test.dart index a3d4d68957..c2d2a114e8 100644 --- a/sdk/flutter/packages/observability/test/global_attributes_test.dart +++ b/sdk/flutter/packages/observability/test/global_attributes_test.dart @@ -2,9 +2,8 @@ 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'; -import 'package:launchdarkly_flutter_observability/src/otel/processors.dart'; -/// The global attribute store and the log pipeline, without a tracer provider. +/// The global attribute store, without a tracer provider. void main() { setUp(GlobalAttributes.resetForTesting); tearDown(GlobalAttributes.resetForTesting); @@ -84,86 +83,4 @@ void main() { expect(GlobalAttributes.fillProperties(null), isNull); }); }); - - group('log pipeline', () { - test('applies globals beneath the call attributes', () { - LDObserve.setAttributes({'k': 'global', 'g': 'global'}); - - final log = LogPipeline(const []).process( - 'hi', - severity: LogSeverity.info, - attributes: {'k': StringAttribute('call')}, - ); - - expect(_values(log.attributes), {'k': 'call', 'g': 'global'}); - }); - - test('runs processors in order and exports their changes', () { - LDObserve.setAttributes({'g': 'global'}); - final seen = >[]; - - final log = - LogPipeline([ - _CallbackLogProcessor((record) { - seen.add(Map.of(record.attributes)); - record.attributes['first'] = true; - record.message = 'redacted'; - }), - _CallbackLogProcessor((record) { - seen.add(Map.of(record.attributes)); - record.severity = LogSeverity.warn; - record.attributes.remove('call'); - }), - ]).process( - 'secret', - severity: LogSeverity.info, - attributes: {'call': IntAttribute(1)}, - ); - - expect(seen, [ - {'g': 'global', 'call': 1}, - {'g': 'global', 'call': 1, 'first': true}, - ]); - expect(log.message, 'redacted'); - expect(log.severity, LogSeverity.warn); - expect(_values(log.attributes), {'g': 'global', 'first': true}); - }); - - test('a throwing processor is skipped and the log still exported', () { - final log = LogPipeline([ - _CallbackLogProcessor((record) { - record.attributes['before'] = 1; - throw StateError('boom'); - }), - _CallbackLogProcessor((record) => record.attributes['after'] = 2), - ]).process('hi', severity: LogSeverity.error); - - expect(log.message, 'hi'); - expect(_values(log.attributes), {'before': 1, 'after': 2}); - }); - }); -} - -Map _values(Map? attributes) => { - for (final entry in {...?attributes}.entries) - entry.key: switch (entry.value) { - IntAttribute(:final value) => value, - DoubleAttribute(:final value) => value, - BooleanAttribute(:final value) => value, - StringAttribute(:final value) => value, - StringListAttribute(:final value) => value, - DoubleListAttribute(:final value) => value, - IntListAttribute(:final value) => value, - BooleanListAttribute(:final value) => value, - InvalidAttribute() => null, - }, -}; - -final class _CallbackLogProcessor extends LDLogRecordProcessor { - const _CallbackLogProcessor(this._onEmit); - - final void Function(LDLogRecord record) _onEmit; - - @override - void onEmit(LDLogRecord record) => _onEmit(record); } diff --git a/sdk/flutter/packages/observability/test/public_api_test.dart b/sdk/flutter/packages/observability/test/public_api_test.dart index 25a590b9d9..1b97054be0 100644 --- a/sdk/flutter/packages/observability/test/public_api_test.dart +++ b/sdk/flutter/packages/observability/test/public_api_test.dart @@ -99,16 +99,6 @@ void main() { ], everyElement(isNotNull)); }); - test('LDLogRecordProcessor and LDLogRecord are usable from the barrel', () { - final record = LDLogRecord(message: 'm', attributes: {'k': 1}); - final void Function(LDLogRecord) onEmit = - const _NoopLogProcessor().onEmit; - onEmit(record); - expect(record.severity, LogSeverity.info); - expect(record.stackTrace, isNull); - expect(record.attributes, {'k': 1}); - }); - test('Span methods match the 1.0 API', () { void checkSpan(Span span) { final void Function() end = span.end; @@ -202,8 +192,6 @@ void main() { expect(options.traces.includeErrors, isTrue); expect(options.traces.includeSpans, isTrue); expect(options.metricsEnabled, isTrue); - expect(options.spanProcessors, isEmpty); - expect(options.logProcessors, isEmpty); }); test('InstrumentationOptions', () { @@ -283,10 +271,3 @@ void main() { }); }); } - -final class _NoopLogProcessor extends LDLogRecordProcessor { - const _NoopLogProcessor(); - - @override - void onEmit(LDLogRecord record) {} -} diff --git a/sdk/flutter/packages/observability/test/web_exporters_test.dart b/sdk/flutter/packages/observability/test/web_exporters_test.dart index b05e785111..0444c7d024 100644 --- a/sdk/flutter/packages/observability/test/web_exporters_test.dart +++ b/sdk/flutter/packages/observability/test/web_exporters_test.dart @@ -4,7 +4,6 @@ import 'package:launchdarkly_flutter_observability/src/otel/exporters/exporter_f 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/processors.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'; @@ -39,7 +38,7 @@ void main() { setUpAll(() { api.registerGlobalTracerProvider( sdk.TracerProviderBase( - processors: buildSpanProcessors(const [], [processor]), + processors: [GlobalAttributesSpanProcessor(), processor], ), ); }); From 5588d92e368b12ec55c816c63d6b685d53f56319 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 28 Oct 2026 17:17:25 -0700 Subject: [PATCH 3/4] chore(flutter): add global attributes repro to example app Co-authored-by: Cursor --- sdk/flutter/example/lib/main.dart | 2 ++ sdk/flutter/example/lib/my_home_page.dart | 4 ++++ sdk/flutter/example/lib/repro.dart | 24 +++++++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 sdk/flutter/example/lib/repro.dart 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)'); +} From 7afd0f72daffab179e017d2c8080cd6f22a54f81 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 29 Oct 2026 18:51:18 -0700 Subject: [PATCH 4/4] fix(flutter): keep log keys over globals on web log events Co-authored-by: Cursor --- .../observability/lib/src/observe_otel.dart | 2 +- .../otel/exporters/exporter_factory_io.dart | 2 +- .../otel/exporters/exporter_factory_stub.dart | 5 +++- .../otel/exporters/exporter_factory_web.dart | 5 +++- .../test/web_exporters_test.dart | 24 +++++++++++++++++++ 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/sdk/flutter/packages/observability/lib/src/observe_otel.dart b/sdk/flutter/packages/observability/lib/src/observe_otel.dart index 452195618f..5b181829b2 100644 --- a/sdk/flutter/packages/observability/lib/src/observe_otel.dart +++ b/sdk/flutter/packages/observability/lib/src/observe_otel.dart @@ -265,7 +265,7 @@ final class ObserveOtel { message, severity: severity, stackTrace: stackTrace, - attributes: GlobalAttributes.fillAttributes(attributes), + attributes: attributes, ); } 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 d79b3f37a5..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 @@ -283,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/test/web_exporters_test.dart b/sdk/flutter/packages/observability/test/web_exporters_test.dart index 0444c7d024..0b9b0fb3bd 100644 --- a/sdk/flutter/packages/observability/test/web_exporters_test.dart +++ b/sdk/flutter/packages/observability/test/web_exporters_test.dart @@ -1,4 +1,5 @@ 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' @@ -103,6 +104,29 @@ void main() { 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', () {