From 1322082f3ecdcb240ef96c0a4a7ce57e3ba92229 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:57:55 -0700 Subject: [PATCH 1/2] feat: Add basic lifecycle instrumentation. --- .../src/instrumentation/instrumentation.dart | 2 + .../lifecycle/lifecycle_conventions.dart | 54 ++++++++++++++++++ .../lifecycle/lifecycle_instrumentation.dart | 57 +++++++++++++++++++ .../platform/io_lifecycle_listener.dart | 31 ++++++++++ .../platform/js_lifecycle_listener.dart | 35 ++++++++++++ .../platform/stub_lifecycle_listener.dart | 10 ++++ .../lib/src/plugin/observability_plugin.dart | 7 +++ 7 files changed, 196 insertions(+) create mode 100644 sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/instrumentation.dart create mode 100644 sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/lifecycle_conventions.dart create mode 100644 sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/lifecycle_instrumentation.dart create mode 100644 sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/io_lifecycle_listener.dart create mode 100644 sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/js_lifecycle_listener.dart create mode 100644 sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/stub_lifecycle_listener.dart diff --git a/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/instrumentation.dart b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/instrumentation.dart new file mode 100644 index 000000000..d613e9ed6 --- /dev/null +++ b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/instrumentation.dart @@ -0,0 +1,2 @@ +/// Interfaces which instrumentations should implement. +interface class Instrumentation {} diff --git a/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/lifecycle_conventions.dart b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/lifecycle_conventions.dart new file mode 100644 index 000000000..b72cd271b --- /dev/null +++ b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/lifecycle_conventions.dart @@ -0,0 +1,54 @@ +import 'dart:ui'; +import '../../api/attribute.dart'; + +const _lifecycleSpanName = "device.app.lifecycle"; +const _flutterAppState = "flutter.app.state"; + +enum LifecycleState { + detached('detached'), + resumed('resumed'), + inactive('inactive'), + hidden('hidden'), + paused('paused'); + + final String stringValue; + + const LifecycleState(String value) : stringValue = value; + + @override + String toString() { + return stringValue; + } + + static LifecycleState fromAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.detached: + return LifecycleState.detached; + case AppLifecycleState.resumed: + return LifecycleState.resumed; + case AppLifecycleState.inactive: + return LifecycleState.inactive; + case AppLifecycleState.hidden: + return LifecycleState.hidden; + case AppLifecycleState.paused: + return LifecycleState.paused; + } + } +} + +/// LaunchDarkly specific lifecycle convention inspired by the otel mobile +/// events semantic convention. +final class LifecycleConventions { + static Map getAttributes({ + required AppLifecycleState state, + }) { + return { + _flutterAppState: StringAttribute( + LifecycleState.fromAppLifecycleState(state).toString(), + ), + }; + } + + /// The name to use for the span. + static const spanName = _lifecycleSpanName; +} diff --git a/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/lifecycle_instrumentation.dart b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/lifecycle_instrumentation.dart new file mode 100644 index 000000000..6f6f3b7c5 --- /dev/null +++ b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/lifecycle_instrumentation.dart @@ -0,0 +1,57 @@ +import 'package:flutter/scheduler.dart'; +import 'package:launchdarkly_flutter_observability/launchdarkly_flutter_observability.dart'; +import 'package:launchdarkly_flutter_observability/src/instrumentation/instrumentation.dart'; +import 'package:launchdarkly_flutter_observability/src/instrumentation/lifecycle/lifecycle_conventions.dart'; + +import '../../observe.dart'; +import 'platform/stub_lifecycle_listener.dart' + if (dart.library.io) 'platform/io_lifecycle_listener.dart' + if (dart.library.js_interop) 'platform/js_lifecycle_listener.dart'; + +final class LifecycleInstrumentation implements Instrumentation { + late final LDAppLifecycleListener _lifecycleListener; + + LifecycleInstrumentation() { + final initialState = SchedulerBinding.instance.lifecycleState; + if (initialState != null) { + _handleApplicationLifecycle(initialState); + } + + _lifecycleListener = LDAppLifecycleListener(); + _lifecycleListener.stream.listen(_handleApplicationLifecycle); + } + + /// The application lifecycle is as follows. + /// Diagram based on: https://api.flutter.dev/flutter/widgets/AppLifecycleListener-class.html + /// +-----------+ onStart +-----------+ + /// | +---------------------------> | + /// | Detached | | Resumed | + /// | | | | + /// +--------^--+ +-^-------+-+ + /// | | | + /// |onDetach onInactive| |onResume + /// | | | + /// | onPause | | + /// +--------+--+ +-----------+onHide +-+-------v-+ + /// | <-------+ <-------+ | + /// | Paused | | Hidden | | Inactive | + /// | +-------> +-------> | + /// +-----------+ +-----------+onShow +-----------+ + /// onRestart + /// + /// On iOS/Android the hidden state is synthesized in the process of pausing, + /// so it will always hide before being paused. On desktop/web platforms + /// hidden may happen when the app is covered. + void _handleApplicationLifecycle(AppLifecycleState state) { + Observe.startSpan( + LifecycleConventions.spanName, + attributes: LifecycleConventions.getAttributes(state: state), + ) + ..setStatus(SpanStatusCode.ok) + ..end(); + } + + void dispose() { + _lifecycleListener.close(); + } +} diff --git a/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/io_lifecycle_listener.dart b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/io_lifecycle_listener.dart new file mode 100644 index 000000000..772b39f2e --- /dev/null +++ b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/io_lifecycle_listener.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +/// Lifecycle listener that uses the Flutter [AppLifecycleListener]. +/// Unfortunately, the [AppLifecycleListener] does not support web very well at +/// the moment, so the [LDAppLifecycleListener] was created. +class LDAppLifecycleListener { + late final StreamController _streamController; + AppLifecycleListener? _underlyingListener; + + LDAppLifecycleListener() { + _streamController = StreamController.broadcast( + onListen: () { + _underlyingListener = AppLifecycleListener( + onStateChange: (state) => _streamController.add(state), + ); + }, + onCancel: () { + _underlyingListener?.dispose(); + _underlyingListener = null; + }, + ); + } + + Stream get stream => _streamController.stream; + + void close() { + _streamController.close(); + } +} diff --git a/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/js_lifecycle_listener.dart b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/js_lifecycle_listener.dart new file mode 100644 index 000000000..bfd5f8f23 --- /dev/null +++ b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/js_lifecycle_listener.dart @@ -0,0 +1,35 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'package:web/web.dart' as web; + +import 'package:flutter/widgets.dart'; + +/// Lifecycle listener that uses the underlying visibility of the html web +/// document to emit events. +class LDAppLifecycleListener { + late final StreamController _streamController; + + LDAppLifecycleListener() { + _streamController = StreamController.broadcast(); + + void listenerFunc(web.Event event) => _streamController.add( + web.document.hidden == true + ? AppLifecycleState.hidden + : AppLifecycleState.resumed, + ); + + _streamController.onListen = () { + web.document.addEventListener('visibilitychange', listenerFunc.toJS); + }; + + _streamController.onCancel = () { + web.document.removeEventListener('visibilitychange', listenerFunc.toJS); + }; + } + + Stream get stream => _streamController.stream; + + void close() { + _streamController.close(); + } +} diff --git a/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/stub_lifecycle_listener.dart b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/stub_lifecycle_listener.dart new file mode 100644 index 000000000..e73075b55 --- /dev/null +++ b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/stub_lifecycle_listener.dart @@ -0,0 +1,10 @@ +import 'package:flutter/widgets.dart'; + +class LDAppLifecycleListener { + Stream get stream => + throw Exception('Stub implementation'); + + void close() { + throw Exception('Stub implementation'); + } +} diff --git a/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/plugin/observability_plugin.dart b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/plugin/observability_plugin.dart index 098349f2d..95b4abcf6 100644 --- a/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/plugin/observability_plugin.dart +++ b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/plugin/observability_plugin.dart @@ -2,6 +2,8 @@ import 'dart:collection'; import 'package:launchdarkly_flutter_client_sdk/launchdarkly_flutter_client_sdk.dart'; import 'package:launchdarkly_flutter_observability/src/api/span_status_code.dart'; +import 'package:launchdarkly_flutter_observability/src/instrumentation/instrumentation.dart'; +import 'package:launchdarkly_flutter_observability/src/instrumentation/lifecycle/lifecycle_instrumentation.dart'; import 'package:launchdarkly_flutter_observability/src/otel/feature_flag_convention.dart'; import 'package:launchdarkly_flutter_observability/src/otel/setup.dart'; @@ -66,10 +68,15 @@ final class _ObservabilityHook extends Hook { /// LaunchDarkly Observability plugin. final class ObservabilityPlugin extends Plugin { + final List _instrumentations = []; final PluginMetadata _metadata = const PluginMetadata( name: _launchDarklyObservabilityPluginName, ); + ObservabilityPlugin() { + _instrumentations.add(LifecycleInstrumentation()); + } + @override void register( LDClient client, From c5fd74e2fcdfaee2171b6277a6ec63cb53770b50 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 29 Sep 2025 08:41:39 -0700 Subject: [PATCH 2/2] Use stable reference for JS listener. --- .../lifecycle/platform/js_lifecycle_listener.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/js_lifecycle_listener.dart b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/js_lifecycle_listener.dart index bfd5f8f23..fc10a1d08 100644 --- a/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/js_lifecycle_listener.dart +++ b/sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/instrumentation/lifecycle/platform/js_lifecycle_listener.dart @@ -18,12 +18,15 @@ class LDAppLifecycleListener { : AppLifecycleState.resumed, ); + /// Use a stable reference for the JS listener. + final listenerJS = listenerFunc.toJS; + _streamController.onListen = () { - web.document.addEventListener('visibilitychange', listenerFunc.toJS); + web.document.addEventListener('visibilitychange', listenerJS); }; _streamController.onCancel = () { - web.document.removeEventListener('visibilitychange', listenerFunc.toJS); + web.document.removeEventListener('visibilitychange', listenerJS); }; }