Skip to content

Commit 3ed0671

Browse files
authored
feat: Add basic lifecycle instrumentation. (#245)
## Summary Adds basic support for lifecycle instrumentation. The code inside the `instrumentation/lifecycle/platform` folder is a port of the code in the LD flutter SDK. Potentially we could move that to a shared library. We split the LD SDK into layers, but there wasn't any need for a flutter specific platform layer sans SDK. (It is dart only, then client-side dart, and then flutter. But no flutter platform library.) ## How did you test this change? Manual testing. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduce lifecycle instrumentation that emits spans on Flutter app state changes across IO and Web platforms, and wire it into the observability plugin. > > - **Observability**: > - **Lifecycle instrumentation**: > - Add `Instrumentation` interface and `LifecycleInstrumentation` that listens to `AppLifecycleState` and emits spans via `Observe` with attributes from `LifecycleConventions`. > - Define `LifecycleConventions` with span name `device.app.lifecycle` and attribute `flutter.app.state` mapped from `AppLifecycleState`. > - Implement platform listeners `platform/io_lifecycle_listener.dart` (uses `AppLifecycleListener`) and `platform/js_lifecycle_listener.dart` (uses document visibility); include a stub fallback. > - **Plugin integration**: > - Update `ObservabilityPlugin` to instantiate and hold `LifecycleInstrumentation` on construction. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d150c19. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 95248c1 commit 3ed0671

File tree

7 files changed

+199
-0
lines changed

7 files changed

+199
-0
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/// Interfaces which instrumentations should implement.
2+
interface class Instrumentation {}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import 'dart:ui';
2+
import '../../api/attribute.dart';
3+
4+
const _lifecycleSpanName = "device.app.lifecycle";
5+
const _flutterAppState = "flutter.app.state";
6+
7+
enum LifecycleState {
8+
detached('detached'),
9+
resumed('resumed'),
10+
inactive('inactive'),
11+
hidden('hidden'),
12+
paused('paused');
13+
14+
final String stringValue;
15+
16+
const LifecycleState(String value) : stringValue = value;
17+
18+
@override
19+
String toString() {
20+
return stringValue;
21+
}
22+
23+
static LifecycleState fromAppLifecycleState(AppLifecycleState state) {
24+
switch (state) {
25+
case AppLifecycleState.detached:
26+
return LifecycleState.detached;
27+
case AppLifecycleState.resumed:
28+
return LifecycleState.resumed;
29+
case AppLifecycleState.inactive:
30+
return LifecycleState.inactive;
31+
case AppLifecycleState.hidden:
32+
return LifecycleState.hidden;
33+
case AppLifecycleState.paused:
34+
return LifecycleState.paused;
35+
}
36+
}
37+
}
38+
39+
/// LaunchDarkly specific lifecycle convention inspired by the otel mobile
40+
/// events semantic convention.
41+
final class LifecycleConventions {
42+
static Map<String, Attribute> getAttributes({
43+
required AppLifecycleState state,
44+
}) {
45+
return {
46+
_flutterAppState: StringAttribute(
47+
LifecycleState.fromAppLifecycleState(state).toString(),
48+
),
49+
};
50+
}
51+
52+
/// The name to use for the span.
53+
static const spanName = _lifecycleSpanName;
54+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import 'package:flutter/scheduler.dart';
2+
import 'package:launchdarkly_flutter_observability/launchdarkly_flutter_observability.dart';
3+
import 'package:launchdarkly_flutter_observability/src/instrumentation/instrumentation.dart';
4+
import 'package:launchdarkly_flutter_observability/src/instrumentation/lifecycle/lifecycle_conventions.dart';
5+
6+
import '../../observe.dart';
7+
import 'platform/stub_lifecycle_listener.dart'
8+
if (dart.library.io) 'platform/io_lifecycle_listener.dart'
9+
if (dart.library.js_interop) 'platform/js_lifecycle_listener.dart';
10+
11+
final class LifecycleInstrumentation implements Instrumentation {
12+
late final LDAppLifecycleListener _lifecycleListener;
13+
14+
LifecycleInstrumentation() {
15+
final initialState = SchedulerBinding.instance.lifecycleState;
16+
if (initialState != null) {
17+
_handleApplicationLifecycle(initialState);
18+
}
19+
20+
_lifecycleListener = LDAppLifecycleListener();
21+
_lifecycleListener.stream.listen(_handleApplicationLifecycle);
22+
}
23+
24+
/// The application lifecycle is as follows.
25+
/// Diagram based on: https://api.flutter.dev/flutter/widgets/AppLifecycleListener-class.html
26+
/// +-----------+ onStart +-----------+
27+
/// | +---------------------------> |
28+
/// | Detached | | Resumed |
29+
/// | | | |
30+
/// +--------^--+ +-^-------+-+
31+
/// | | |
32+
/// |onDetach onInactive| |onResume
33+
/// | | |
34+
/// | onPause | |
35+
/// +--------+--+ +-----------+onHide +-+-------v-+
36+
/// | <-------+ <-------+ |
37+
/// | Paused | | Hidden | | Inactive |
38+
/// | +-------> +-------> |
39+
/// +-----------+ +-----------+onShow +-----------+
40+
/// onRestart
41+
///
42+
/// On iOS/Android the hidden state is synthesized in the process of pausing,
43+
/// so it will always hide before being paused. On desktop/web platforms
44+
/// hidden may happen when the app is covered.
45+
void _handleApplicationLifecycle(AppLifecycleState state) {
46+
Observe.startSpan(
47+
LifecycleConventions.spanName,
48+
attributes: LifecycleConventions.getAttributes(state: state),
49+
)
50+
..setStatus(SpanStatusCode.ok)
51+
..end();
52+
}
53+
54+
void dispose() {
55+
_lifecycleListener.close();
56+
}
57+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter/widgets.dart';
4+
5+
/// Lifecycle listener that uses the Flutter [AppLifecycleListener].
6+
/// Unfortunately, the [AppLifecycleListener] does not support web very well at
7+
/// the moment, so the [LDAppLifecycleListener] was created.
8+
class LDAppLifecycleListener {
9+
late final StreamController<AppLifecycleState> _streamController;
10+
AppLifecycleListener? _underlyingListener;
11+
12+
LDAppLifecycleListener() {
13+
_streamController = StreamController.broadcast(
14+
onListen: () {
15+
_underlyingListener = AppLifecycleListener(
16+
onStateChange: (state) => _streamController.add(state),
17+
);
18+
},
19+
onCancel: () {
20+
_underlyingListener?.dispose();
21+
_underlyingListener = null;
22+
},
23+
);
24+
}
25+
26+
Stream<AppLifecycleState> get stream => _streamController.stream;
27+
28+
void close() {
29+
_streamController.close();
30+
}
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import 'dart:async';
2+
import 'dart:js_interop';
3+
import 'package:web/web.dart' as web;
4+
5+
import 'package:flutter/widgets.dart';
6+
7+
/// Lifecycle listener that uses the underlying visibility of the html web
8+
/// document to emit events.
9+
class LDAppLifecycleListener {
10+
late final StreamController<AppLifecycleState> _streamController;
11+
12+
LDAppLifecycleListener() {
13+
_streamController = StreamController.broadcast();
14+
15+
void listenerFunc(web.Event event) => _streamController.add(
16+
web.document.hidden == true
17+
? AppLifecycleState.hidden
18+
: AppLifecycleState.resumed,
19+
);
20+
21+
/// Use a stable reference for the JS listener.
22+
final listenerJS = listenerFunc.toJS;
23+
24+
_streamController.onListen = () {
25+
web.document.addEventListener('visibilitychange', listenerJS);
26+
};
27+
28+
_streamController.onCancel = () {
29+
web.document.removeEventListener('visibilitychange', listenerJS);
30+
};
31+
}
32+
33+
Stream<AppLifecycleState> get stream => _streamController.stream;
34+
35+
void close() {
36+
_streamController.close();
37+
}
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import 'package:flutter/widgets.dart';
2+
3+
class LDAppLifecycleListener {
4+
Stream<AppLifecycleState> get stream =>
5+
throw Exception('Stub implementation');
6+
7+
void close() {
8+
throw Exception('Stub implementation');
9+
}
10+
}

sdk/@launchdarkly/launchdarkly_flutter_observability/lib/src/plugin/observability_plugin.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import 'dart:collection';
22

33
import 'package:launchdarkly_flutter_client_sdk/launchdarkly_flutter_client_sdk.dart';
44
import 'package:launchdarkly_flutter_observability/src/api/span_status_code.dart';
5+
import 'package:launchdarkly_flutter_observability/src/instrumentation/instrumentation.dart';
6+
import 'package:launchdarkly_flutter_observability/src/instrumentation/lifecycle/lifecycle_instrumentation.dart';
57
import 'package:launchdarkly_flutter_observability/src/otel/feature_flag_convention.dart';
68
import 'package:launchdarkly_flutter_observability/src/otel/setup.dart';
79

@@ -66,10 +68,15 @@ final class _ObservabilityHook extends Hook {
6668

6769
/// LaunchDarkly Observability plugin.
6870
final class ObservabilityPlugin extends Plugin {
71+
final List<Instrumentation> _instrumentations = [];
6972
final PluginMetadata _metadata = const PluginMetadata(
7073
name: _launchDarklyObservabilityPluginName,
7174
);
7275

76+
ObservabilityPlugin() {
77+
_instrumentations.add(LifecycleInstrumentation());
78+
}
79+
7380
@override
7481
void register(
7582
LDClient client,

0 commit comments

Comments
 (0)