Skip to content

Commit e533388

Browse files
committed
feat: Add basic lifecycle instrumentation.
1 parent 8f882c5 commit e533388

File tree

7 files changed

+187
-0
lines changed

7 files changed

+187
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
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: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import 'package:flutter/scheduler.dart';
2+
import 'package:launchdarkly_flutter_observability/src/instrumentation/instrumentation.dart';
3+
import 'package:launchdarkly_flutter_observability/src/instrumentation/lifecycle/lifecycle_conventions.dart';
4+
5+
import '../../observe.dart';
6+
import 'platform/stub_lifecycle_listener.dart'
7+
if (dart.library.io) 'platform/io_lifecycle_listener.dart'
8+
if (dart.library.js_interop) 'platform/js_lifecycle_listener.dart';
9+
10+
final class LifecycleInstrumentation implements Instrumentation {
11+
late final LDAppLifecycleListener _lifecycleListener;
12+
13+
LifecycleInstrumentation() {
14+
final initialState = SchedulerBinding.instance.lifecycleState;
15+
if (initialState != null) {
16+
_handleApplicationLifecycle(initialState);
17+
}
18+
19+
_lifecycleListener = LDAppLifecycleListener();
20+
_lifecycleListener.stream.listen(_handleApplicationLifecycle);
21+
}
22+
23+
/// The application lifecycle is as follows.
24+
/// Diagram based on: https://api.flutter.dev/flutter/widgets/AppLifecycleListener-class.html
25+
/// +-----------+ onStart +-----------+
26+
/// | +---------------------------> |
27+
/// | Detached | | Resumed |
28+
/// | | | |
29+
/// +--------^--+ +-^-------+-+
30+
/// | | |
31+
/// |onDetach onInactive| |onResume
32+
/// | | |
33+
/// | onPause | |
34+
/// +--------+--+ +-----------+onHide +-+-------v-+
35+
/// | <-------+ <-------+ |
36+
/// | Paused | | Hidden | | Inactive |
37+
/// | +-------> +-------> |
38+
/// +-----------+ +-----------+onShow +-----------+
39+
/// onRestart
40+
///
41+
/// On iOS/Android the hidden state is synthesized in the process of pausing,
42+
/// so it will always hide before being paused. On desktop/web platforms
43+
/// hidden may happen when the app is covered.
44+
void _handleApplicationLifecycle(AppLifecycleState state) {
45+
Observe.startSpan(
46+
LifecycleConventions.spanName,
47+
attributes: LifecycleConventions.getAttributes(state: state),
48+
).end();
49+
}
50+
51+
void dispose() {
52+
_lifecycleListener.close();
53+
}
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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(onListen: () {
14+
_underlyingListener = AppLifecycleListener(
15+
onStateChange: (state) => _streamController.add(state));
16+
}, onCancel: () {
17+
_underlyingListener?.dispose();
18+
_underlyingListener = null;
19+
});
20+
}
21+
22+
Stream<AppLifecycleState> get stream => _streamController.stream;
23+
24+
void close() {
25+
_streamController.close();
26+
}
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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) =>
16+
_streamController.add(web.document.hidden == true
17+
? AppLifecycleState.hidden
18+
: AppLifecycleState.resumed);
19+
20+
_streamController.onListen = () {
21+
web.document.addEventListener('visibilitychange', listenerFunc.toJS);
22+
};
23+
24+
_streamController.onCancel = () {
25+
web.document.removeEventListener('visibilitychange', listenerFunc.toJS);
26+
};
27+
}
28+
29+
Stream<AppLifecycleState> get stream => _streamController.stream;
30+
31+
void close() {
32+
_streamController.close();
33+
}
34+
}
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)