From 852b8430941b17144388f1d2922f557822296782 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 9 Oct 2026 14:49:10 -0600 Subject: [PATCH 1/5] feat(flags)!: add retained first-flags callbacks Notify applications when the first usable flag configuration is installed. Core retains the immutable key inventory and delivers callbacks asynchronously in their registration zones; Flutter bridges registration and cancellation to core. Validate with 148 core and Flutter tests, static analysis, formatting, and compiled CLI and Flutter examples. BREAKING CHANGE: Custom DatadogFlagsClient implementations must implement onFirstFlags. --- packages/datadog_flags/README.md | 27 ++ .../example/bin/typed_evaluation.dart | 13 +- packages/datadog_flags/lib/datadog_flags.dart | 3 + .../lib/src/default_flags_client.dart | 6 + .../datadog_flags/lib/src/flags_client.dart | 20 +- .../lib/src/flags_client_event.dart | 41 ++ .../lib/src/flags_repository.dart | 64 +++ .../lib/src/no_op_flags_client.dart | 8 +- packages/datadog_flags/pubspec.yaml | 3 +- .../test/first_flags_callback_test.dart | 433 ++++++++++++++++++ .../test/first_flags_registration_test.dart | 284 ++++++++++++ .../test/flags_client_event_test.dart | 46 ++ packages/datadog_flags_flutter/README.md | 20 + .../example/lib/main.dart | 47 +- .../example/pubspec.yaml | 3 + .../lib/datadog_flags_flutter.dart | 2 + .../lib/src/datadog_flags_plugin.dart | 56 +++ packages/datadog_flags_flutter/pubspec.yaml | 1 + .../test/first_flags_registration_test.dart | 316 +++++++++++++ .../test/helpers/first_flags_test_client.dart | 15 + 20 files changed, 1388 insertions(+), 20 deletions(-) create mode 100644 packages/datadog_flags/lib/src/flags_client_event.dart create mode 100644 packages/datadog_flags/test/first_flags_callback_test.dart create mode 100644 packages/datadog_flags/test/first_flags_registration_test.dart create mode 100644 packages/datadog_flags/test/flags_client_event_test.dart create mode 100644 packages/datadog_flags_flutter/test/first_flags_registration_test.dart create mode 100644 packages/datadog_flags_flutter/test/helpers/first_flags_test_client.dart diff --git a/packages/datadog_flags/README.md b/packages/datadog_flags/README.md index dcefcabfb..25fbed14d 100644 --- a/packages/datadog_flags/README.md +++ b/packages/datadog_flags/README.md @@ -363,3 +363,30 @@ dart run datadog_flags_example:typed_evaluation \ The repository also includes a Flutter example screen in `examples/simple_example` that can initialize the SDK, refresh assignments, and evaluate multiple flag types. + +## First installed flags + +Register after obtaining the client, before or after initializing its context: + +```dart +final client = DatadogFlags.instance.sharedClient(); +final unregister = client.onFirstFlags((event) { + print('First installed flags: ${event.flagsChanged}'); + final details = client.getBooleanDetails( + key: 'checkout.enabled', + defaultValue: false, + ); + print(details.value); +}); +// Call unregister() when the application no longer needs this notification. +``` + +The callback receives the keys from the first accepted cache or network +configuration, including an empty list for an empty configuration. Late +registrations receive the same event. Delivery runs in a later microtask; +evaluations in the callback read current flag values. If no usable configuration +is installed, the callback remains pending until you unregister it. + +Call the returned function to cancel delivery before the callback starts. +See the [typed CLI example](example/bin/typed_evaluation.dart) and +`DatadogFlagsClient.onFirstFlags` API documentation for details. diff --git a/packages/datadog_flags/example/bin/typed_evaluation.dart b/packages/datadog_flags/example/bin/typed_evaluation.dart index c5ba084ea..004f9683b 100644 --- a/packages/datadog_flags/example/bin/typed_evaluation.dart +++ b/packages/datadog_flags/example/bin/typed_evaluation.dart @@ -45,6 +45,10 @@ Future main(List arguments) async { ); final flags = datadogFlags.sharedClient(); + final unregister = flags.onFirstFlags((event) { + stdout.writeln('First installed flags: ${event.flagsChanged}'); + _printDetails(_evaluate(flags, flagKey, flagType)); + }); try { await flags.initialize( FlagsEvaluationContext( @@ -56,14 +60,17 @@ Future main(List arguments) async { stderr.writeln(error.message); } - final details = _evaluate(flags, flagKey, flagType); + _printDetails(_evaluate(flags, flagKey, flagType)); + unregister(); + await datadogFlags.disable(); +} + +void _printDetails(FlagDetails details) { stdout.writeln('key: ${details.key}'); stdout.writeln('value: ${jsonEncode(details.value)}'); stdout.writeln('variant: ${details.variant ?? '(none)'}'); stdout.writeln('reason: ${details.reason ?? '(none)'}'); stdout.writeln('error: ${details.error?.name ?? '(none)'}'); - - await datadogFlags.disable(); } ArgParser _argumentParser() { diff --git a/packages/datadog_flags/lib/datadog_flags.dart b/packages/datadog_flags/lib/datadog_flags.dart index 9c69c53e7..ab0c4f27e 100644 --- a/packages/datadog_flags/lib/datadog_flags.dart +++ b/packages/datadog_flags/lib/datadog_flags.dart @@ -19,3 +19,6 @@ export 'src/flags_error.dart' show FlagEvaluationError, FlagsInitializationTimeoutException; export 'src/flags_store.dart' show DatadogFlagsStore, FlagsData; export 'src/evaluation_context.dart' show FlagsEvaluationContext; + +export 'src/flags_client_event.dart' + show FlagsClientEvent, FlagsClientEventType; diff --git a/packages/datadog_flags/lib/src/default_flags_client.dart b/packages/datadog_flags/lib/src/default_flags_client.dart index 435c91ecd..1c8c5da58 100644 --- a/packages/datadog_flags/lib/src/default_flags_client.dart +++ b/packages/datadog_flags/lib/src/default_flags_client.dart @@ -9,6 +9,7 @@ import 'evaluation_context.dart'; import 'exposure_logger.dart'; import 'flags_client.dart'; import 'flags_error.dart'; +import 'flags_client_event.dart'; import 'flags_repository.dart'; class DefaultDatadogFlagsClient implements DatadogFlagsClient { @@ -29,6 +30,11 @@ class DefaultDatadogFlagsClient implements DatadogFlagsClient { _exposureLogger = exposureLogger, _evaluationAggregator = evaluationAggregator; + @override + void Function() onFirstFlags( + void Function(FlagsClientEvent event) listener) => + _repository.onFirstFlags(listener); + @override Future initialize(FlagsEvaluationContext context) async { await _repository.initialize(context); diff --git a/packages/datadog_flags/lib/src/flags_client.dart b/packages/datadog_flags/lib/src/flags_client.dart index 2db1ae056..996f01b55 100644 --- a/packages/datadog_flags/lib/src/flags_client.dart +++ b/packages/datadog_flags/lib/src/flags_client.dart @@ -5,6 +5,7 @@ import 'evaluation_context.dart'; import 'flags_error.dart'; +import 'flags_client_event.dart'; /// Evaluates feature flags for one current evaluation context. /// @@ -15,6 +16,23 @@ abstract interface class DatadogFlagsClient { /// Stable name assigned by [DatadogFlags.sharedClient]. String get name; + /// Calls [listener] once with the first accepted cache or network configuration. + /// The event contains its flag keys, including an empty list for an empty + /// configuration. Later registrations receive the same retained event. + /// If no usable configuration is installed, the callback remains pending. + /// + /// Delivery runs in a later microtask in the registration zone. The callback + /// can evaluate current flag values and start asynchronous work. Synchronous + /// exceptions are caught; the application handles errors from its async work. + /// Registration does not fetch flags or wait for initialization to complete. + /// + /// Returns an unregister function that prevents delivery until the callback + /// starts. Calling it repeatedly is safe. Reset preserves the retained event; + /// obtain a new shared client after SDK re-enable. + void Function() onFirstFlags( + void Function(FlagsClientEvent event) listener, + ); + /// Fetches assignments for [context] and makes them available to evaluations. /// /// The first call completes when initialization finishes or the configured @@ -26,7 +44,7 @@ abstract interface class DatadogFlagsClient { /// If a later call supersedes the first call, the first call remains bounded /// by its original deadline. The later call does not use this timeout. /// - /// Evaluations made before initialization completes return their provided + /// Evaluations made before assignments are available return their provided /// default value with a `providerNotReady` error. Future initialize( FlagsEvaluationContext context, diff --git a/packages/datadog_flags/lib/src/flags_client_event.dart b/packages/datadog_flags/lib/src/flags_client_event.dart new file mode 100644 index 000000000..dd6792c97 --- /dev/null +++ b/packages/datadog_flags/lib/src/flags_client_event.dart @@ -0,0 +1,41 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. This product includes software +// developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import 'package:meta/meta.dart'; + +/// The kinds of notifications emitted by a flag client. +enum FlagsClientEventType { + /// A flag configuration has been installed. + configurationChanged('CONFIGURATION_CHANGED'); + + /// Cross-SDK event name corresponding to this Dart case. + final String code; + + const FlagsClientEventType(this.code); +} + +/// Immutable event data, independent of event registration and delivery. +@immutable +final class FlagsClientEvent { + /// The kind of event represented by this value. + final FlagsClientEventType type; + + /// Keys in the accepted configuration represented by this event. + /// + /// `null` means keys were not supplied; an empty list means an explicitly + /// supplied empty list. This value does not infer or evaluate any keys. + final List? flagsChanged; + + /// SDK-internal event construction; applications receive events from + /// [DatadogFlagsClient.onFirstFlags] rather than constructing them. + /// + /// Copies [flagsChanged] into an unmodifiable list when supplied. Later + /// changes to the source list cannot change this value. + @internal + FlagsClientEvent({required this.type, List? flagsChanged}) + : flagsChanged = flagsChanged == null + ? null + : List.unmodifiable(flagsChanged); +} diff --git a/packages/datadog_flags/lib/src/flags_repository.dart b/packages/datadog_flags/lib/src/flags_repository.dart index 15b7953d0..5d7833d45 100644 --- a/packages/datadog_flags/lib/src/flags_repository.dart +++ b/packages/datadog_flags/lib/src/flags_repository.dart @@ -11,6 +11,7 @@ import 'assignment.dart'; import 'evaluation_context.dart'; import 'flag_assignments_fetcher.dart'; import 'flags_error.dart'; +import 'flags_client_event.dart'; import 'flags_store.dart'; import 'json_value.dart'; @@ -36,6 +37,9 @@ class FlagsRepository { @visibleForTesting final Timer Function(Duration, void Function()) scheduleInitializationTimeout; + FlagsClientEvent? _firstFlagsEvent; + final _firstFlagsRegistrations = <_FirstFlagsRegistration>{}; + _InstalledFlagsState? _state; _CancelToken? _currentToken; bool _didStartInitialization = false; @@ -56,6 +60,33 @@ class FlagsRepository { bool get isRestoredFromStore => _state?.restoredFromStore ?? false; + void Function() onFirstFlags(void Function(FlagsClientEvent) callback) { + final registration = _FirstFlagsRegistration(callback); + final event = _firstFlagsEvent; + if (event != null) { + registration.schedule(event); + } else { + _firstFlagsRegistrations.add(registration); + registration.removePending = + () => _firstFlagsRegistrations.remove(registration); + } + return registration.cancel; + } + + void _captureFirstFlags(FlagsData data) { + if (_firstFlagsEvent != null) return; + final event = FlagsClientEvent( + type: FlagsClientEventType.configurationChanged, + flagsChanged: data.flags.keys.toList(), + ); + _firstFlagsEvent = event; + final registrations = _firstFlagsRegistrations.toList(); + _firstFlagsRegistrations.clear(); + for (final registration in registrations) { + registration.schedule(event); + } + } + Future initialize(FlagsEvaluationContext context) { _currentToken?.cancel(); final token = _CancelToken(); @@ -102,6 +133,7 @@ class FlagsRepository { data: matchingCached, restoredFromStore: true, ); + _captureFirstFlags(matchingCached); } try { @@ -115,6 +147,7 @@ class FlagsRepository { date: dateProvider(), ); _state = _InstalledFlagsState(data: data, restoredFromStore: false); + _captureFirstFlags(data); await _writeCached(data); } catch (_) { if (!token.isCanceled && matchingCached == null) { @@ -283,3 +316,34 @@ bool _jsonValuesMatch(Object? left, Object? right) { } return left == right; } + +// Only this record owns the app callback. Queued work never copies it, so +// cancellation releases its captures even when a microtask is already queued. +class _FirstFlagsRegistration { + void Function(FlagsClientEvent)? _callback; + void Function()? removePending; + + _FirstFlagsRegistration(void Function(FlagsClientEvent) callback) + : _callback = Zone.current.bindUnaryCallback(callback); + + void cancel() { + _callback = null; + final remove = removePending; + removePending = null; + remove?.call(); + } + + void schedule(FlagsClientEvent event) { + removePending = null; + scheduleMicrotask(() { + final callback = _callback; + _callback = null; + if (callback == null) return; + try { + callback(event); + } catch (_) { + // One application failure must not poison installation or other listeners. + } + }); + } +} diff --git a/packages/datadog_flags/lib/src/no_op_flags_client.dart b/packages/datadog_flags/lib/src/no_op_flags_client.dart index a894c3d39..9fb380975 100644 --- a/packages/datadog_flags/lib/src/no_op_flags_client.dart +++ b/packages/datadog_flags/lib/src/no_op_flags_client.dart @@ -6,12 +6,18 @@ import 'evaluation_context.dart'; import 'flags_client.dart'; import 'flags_error.dart'; +import 'flags_client_event.dart'; class NoOpDatadogFlagsClient implements DatadogFlagsClient { @override final String name; - const NoOpDatadogFlagsClient({required this.name}); + NoOpDatadogFlagsClient({required this.name}); + + @override + void Function() onFirstFlags( + void Function(FlagsClientEvent event) listener) => + () {}; @override Future initialize(FlagsEvaluationContext context) async {} diff --git a/packages/datadog_flags/pubspec.yaml b/packages/datadog_flags/pubspec.yaml index c809a6ef2..675adc5e5 100644 --- a/packages/datadog_flags/pubspec.yaml +++ b/packages/datadog_flags/pubspec.yaml @@ -15,10 +15,11 @@ environment: dependencies: http: ^1.0.0 json_annotation: ^4.9.0 - meta: ^1.0.0 + meta: ^1.3.0 uuid: ^4.0.0 dev_dependencies: + fake_async: ^1.3.1 build_runner: 2.4.15 json_serializable: 6.9.5 lints: '>=5.0.0' diff --git a/packages/datadog_flags/test/first_flags_callback_test.dart b/packages/datadog_flags/test/first_flags_callback_test.dart new file mode 100644 index 000000000..1af94b21c --- /dev/null +++ b/packages/datadog_flags/test/first_flags_callback_test.dart @@ -0,0 +1,433 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. This product includes software +// developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:datadog_flags/datadog_flags.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; + +const _context = FlagsEvaluationContext(targetingKey: 'example-user'); + +Map _flag(bool value) => { + 'allocationKey': 'allocation', + 'variationKey': 'variant', + 'variationType': 'boolean', + 'variationValue': value, + 'reason': 'TARGETING_MATCH', + 'doLog': true, + }; + +http.Response _response( + [Map flags = const {'checkout.enabled': true}]) => + http.Response( + jsonEncode({ + 'data': { + 'attributes': { + 'flags': flags.map((key, value) => MapEntry(key, _flag(value))), + } + } + }), + 200); + +FlagsData _cached({String subject = 'example-user', bool empty = false}) => + FlagsData.fromJson({ + 'context': {'targetingKey': subject}, + 'date': '2026-10-01T00:00:00.000Z', + 'flags': empty ? {} : {'checkout.enabled': _flag(false)}, + }); + +class _Store implements DatadogFlagsStore { + final Future Function() load; + Future Function(FlagsData)? save; + _Store(this.load); + @override + Future read(String name) async => await load(); + @override + Future write(String name, FlagsData data) async { + await save?.call(data); + } + + @override + Future delete(String name) async {} +} + +Future _owner({ + required void Function(FlagsClientEvent) callback, + DatadogFlagsStore? store, + Future Function(http.Request)? request, + Duration? timeout, +}) async { + final flags = DatadogFlags(); + await flags.enable( + configuration: DatadogFlagsConfiguration( + datadogConfig: const DatadogFlagsConfig( + clientToken: 'client-token', env: 'test', site: DatadogFlagsSite.us1), + store: store, + initializationTimeout: timeout, + httpClient: MockClient(request ?? (_) async => _response()), + )); + flags.sharedClient().onFirstFlags(callback); + addTearDown(flags.disable); + return flags; +} + +void main() { + test( + 'cache callback can evaluate before pending network initialization completes', + () async { + final network = Completer(); + final delivered = Completer(); + late DatadogFlagsClient client; + FlagDetails? early; + final events = []; + final flags = await _owner( + store: _Store(() async => _cached()), + request: (_) => network.future, + callback: (event) { + early = client.getBooleanDetails( + key: 'checkout.enabled', defaultValue: true); + events.add(event); + delivered.complete(); + }); + client = flags.sharedClient(); + var completed = false; + final init = client.initialize(_context).then((_) => completed = true); + await delivered.future; + expect(completed, isFalse); + expect(early!.value, isFalse); + expect(early!.reason, 'CACHED'); + expect(events.single.flagsChanged, ['checkout.enabled']); + network.complete(_response()); + await init; + expect(events, hasLength(1)); + expect( + client + .getBooleanDetails(key: 'checkout.enabled', defaultValue: false) + .value, + isTrue); + }); + + for (final fromCache in [false, true]) { + test('accepted empty ${fromCache ? 'cache' : 'network'} notifies with []', + () async { + final delivered = Completer(); + final network = Completer(); + final flags = await _owner( + callback: delivered.complete, + store: fromCache ? _Store(() async => _cached(empty: true)) : null, + request: (_) => + fromCache ? network.future : Future.value(_response({}))); + var initialized = false; + final initialize = flags + .sharedClient() + .initialize(_context) + .then((_) => initialized = true); + final event = await delivered.future; + expect(event.type, FlagsClientEventType.configurationChanged); + expect(event.flagsChanged, isEmpty); + if (fromCache) { + expect(initialized, isFalse); + network.complete(_response({'later': true})); + } + await initialize; + }); + } + + for (final empty in [false, true]) { + test( + 'accepted cache keys are immutable and detached from source (empty=$empty)', + () async { + final cached = _cached(empty: empty); + final originalKeys = cached.flags.keys.toList(); + final network = Completer(); + final delivered = Completer(); + final flags = await _owner( + callback: delivered.complete, + store: _Store(() async => cached), + request: (_) => network.future); + final initialize = flags.sharedClient().initialize(_context); + final event = await delivered.future; + cached.flags.clear(); + cached.flags['later'] = _cached().flags.values.single; + expect(event.flagsChanged, originalKeys); + expect(() => event.flagsChanged!.add('mutation'), throwsUnsupportedError); + expect(() => event.flagsChanged!.clear(), throwsUnsupportedError); + if (!empty) { + expect( + () => event.flagsChanged![0] = 'mutation', throwsUnsupportedError); + } + network.complete(_response({'network': true})); + await initialize; + final replay = Completer(); + flags.sharedClient().onFirstFlags(replay.complete); + expect(await replay.future, same(event)); + expect(event.flagsChanged, originalKeys); + }); + } + + for (final cacheCase in ['missing', 'invalid', 'mismatched', 'late']) { + test('$cacheCase cache cannot claim notification', () async { + final disk = Completer(); + final events = []; + final store = _Store(() async { + if (cacheCase == 'invalid') throw FormatException('invalid cache'); + if (cacheCase == 'mismatched') return _cached(subject: 'someone-else'); + if (cacheCase == 'late') return await disk.future; + return null; + }); + final flags = await _owner( + callback: events.add, + store: store, + request: (_) async => _response({'network-only': true})); + await flags.sharedClient().initialize(_context); + if (cacheCase == 'late') disk.complete(_cached()); + await Future.delayed(Duration.zero); + expect(events.single.flagsChanged, ['network-only']); + }); + } + + final failedLoads = Function()>{ + 'transport failure': () async => throw StateError('offline'), + 'HTTP failure': () async => http.Response('{}', 503), + 'malformed JSON': () async => http.Response('{', 200), + 'missing configuration': () async => http.Response('{}', 200), + 'missing flags': () async => + http.Response('{"data":{"attributes":{}}}', 200), + 'invalid flags type': () async => + http.Response('{"data":{"attributes":{"flags":[]}}}', 200), + }; + for (final failure in failedLoads.entries) { + test('${failure.key} does not consume first signal; valid empty load does', + () async { + final events = []; + var attempts = 0; + final flags = await _owner( + callback: events.add, + request: (_) => + ++attempts == 1 ? failure.value() : Future.value(_response({}))); + final client = flags.sharedClient(); + await client.initialize(_context); + expect(events, isEmpty); + expect( + client + .getBooleanDetails(key: 'checkout.enabled', defaultValue: false) + .error, + FlagEvaluationError.providerNotReady); + await client.initialize(_context); + expect(attempts, 2); + expect(events, hasLength(1)); + expect(events.single.flagsChanged, isEmpty); + expect( + client + .getBooleanDetails(key: 'checkout.enabled', defaultValue: false) + .error, + FlagEvaluationError.flagNotFound); + }); + } + + test('retained event contains all accepted keys and no rejected entries', + () async { + final events = []; + final flags = await _owner( + callback: events.add, + request: (_) async => http.Response( + jsonEncode({ + 'data': { + 'attributes': { + 'flags': { + 'first': _flag(true), + 'invalid': null, + 'second': _flag(false), + } + } + } + }), + 200)); + await flags.sharedClient().initialize(_context); + expect(events, hasLength(1)); + expect(events.single.flagsChanged, unorderedEquals(['first', 'second'])); + }); + + test('late replay does not fetch or access persistence', () async { + var requests = 0; + var reads = 0; + var writes = 0; + final events = []; + final store = _Store(() async { + reads++; + return null; + }) + ..save = (_) async { + writes++; + }; + final flags = await _owner( + callback: events.add, + store: store, + request: (_) async { + requests++; + return _response(); + }); + final client = flags.sharedClient(); + await client.initialize(_context); + expect((requests, reads, writes), (1, 1, 1)); + final first = events.single; + client.onFirstFlags(events.add); + expect(events, hasLength(1)); + await Future.delayed(Duration.zero); + expect(events, hasLength(2)); + expect(events.last, same(first)); + expect((requests, reads, writes), (1, 1, 1)); + }); + + for (final failUpdate in [false, true]) { + test( + 'late replay keeps original keys after ${failUpdate ? "failed" : "successful"} context update', + () async { + var requests = 0; + final contexts = []; + final events = []; + final flags = await _owner( + callback: events.add, + request: (request) async { + final body = jsonDecode(request.body) as Map; + contexts.add(body['data']['attributes']['subject']['targeting_key'] + as String); + if (++requests == 1) return _response({'original': true}); + if (failUpdate) throw StateError('offline'); + return _response({'current': false}); + }); + final client = flags.sharedClient(); + await client.initialize(_context); + final first = events.single; + await client.initialize( + const FlagsEvaluationContext(targetingKey: 'updated-user')); + final delivered = Completer(); + FlagDetails? current; + client.onFirstFlags((event) { + current = client.getBooleanDetails(key: 'current', defaultValue: true); + delivered.complete(event); + }); + expect(delivered.isCompleted, isFalse); + expect(await delivered.future, same(first)); + expect(first.flagsChanged, ['original']); + expect(contexts, ['example-user', 'updated-user']); + expect(current!.value, failUpdate); + expect(current!.error, + failUpdate ? FlagEvaluationError.providerNotReady : null); + expect(events, hasLength(1)); + }); + } + + test('superseded network is rejected before claiming first notification', + () async { + final oldResponse = Completer(); + final events = []; + var requests = 0; + final flags = await _owner( + callback: events.add, + request: (_) => ++requests == 1 + ? oldResponse.future + : Future.value(_response({'current': true}))); + final client = flags.sharedClient(); + final old = client.initialize(_context); + await client + .initialize(const FlagsEvaluationContext(targetingKey: 'new-user')); + oldResponse.complete(_response({'obsolete': true})); + await old; + expect(events.single.flagsChanged, ['current']); + }); + + test('callback is consumed before reentrant reset and initialization', + () async { + var calls = 0; + late DatadogFlagsClient client; + Future? refresh; + final flags = await _owner(callback: (_) { + calls++; + refresh = client.reset().then((_) => client + .initialize(const FlagsEvaluationContext(targetingKey: 'new-user'))); + }); + client = flags.sharedClient(); + await client.initialize(_context); + await refresh; + expect(calls, 1); + final details = + client.getBooleanDetails(key: 'checkout.enabled', defaultValue: false); + expect(details.value, isTrue); + expect(details.error, isNull); + }); + + test( + 'synchronous callback exception does not clear assignments or fail initialization', + () async { + var calls = 0; + final flags = await _owner(callback: (_) { + calls++; + throw StateError('app'); + }); + final client = flags.sharedClient(); + await client.initialize(_context); + expect( + client + .getBooleanDetails(key: 'checkout.enabled', defaultValue: false) + .value, + isTrue); + await client.initialize(_context); + expect(calls, 1); + }); + + test('each client independently retains its first event', () async { + final events = []; + final flags = await _owner(callback: events.add); + await flags.sharedClient().initialize(_context); + await flags.sharedClient().initialize(_context); + flags.sharedClient(name: 'other').onFirstFlags(events.add); + await flags.sharedClient(name: 'other').initialize(_context); + expect(events, hasLength(2)); + }); + + test('notification does not wait for persistence or emit telemetry', + () async { + final saved = Completer(); + final delivered = Completer(); + final requests = []; + final store = _Store(() async => null)..save = (_) => saved.future; + final flags = await _owner( + store: store, + callback: (_) => delivered.complete(), + request: (request) async { + requests.add(request.url); + return _response(); + }); + var completed = false; + final init = + flags.sharedClient().initialize(_context).then((_) => completed = true); + await delivered.future; + expect(completed, isFalse); + saved.complete(); + await init; + await flags.disable(); + expect(requests, hasLength(1)); + }); + + test( + 'timeout does not fabricate an event but a late accepted result can notify', + () async { + final response = Completer(); + final delivered = Completer(); + final flags = await _owner( + timeout: const Duration(milliseconds: 1), + callback: (_) => delivered.complete(), + request: (_) => response.future); + await expectLater(flags.sharedClient().initialize(_context), + throwsA(isA())); + expect(delivered.isCompleted, isFalse); + response.complete(_response()); + await delivered.future; + }); +} diff --git a/packages/datadog_flags/test/first_flags_registration_test.dart b/packages/datadog_flags/test/first_flags_registration_test.dart new file mode 100644 index 000000000..d80612345 --- /dev/null +++ b/packages/datadog_flags/test/first_flags_registration_test.dart @@ -0,0 +1,284 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. This product includes software +// developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import 'dart:async'; +import 'dart:convert'; +import 'package:datadog_flags/datadog_flags.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; +import 'package:fake_async/fake_async.dart'; + +const _context = FlagsEvaluationContext(targetingKey: 'user'); +Future _owner( + {DatadogFlagsStore? store, void Function()? onRequest}) async { + final owner = DatadogFlags(); + await owner.enable( + configuration: DatadogFlagsConfiguration( + datadogConfig: const DatadogFlagsConfig( + clientToken: 'token', env: 'test', site: DatadogFlagsSite.us1), + store: store, + httpClient: MockClient((_) async { + onRequest?.call(); + return http.Response( + jsonEncode({ + 'data': { + 'attributes': { + 'flags': { + 'checkout.enabled': { + 'allocationKey': 'allocation', + 'variationKey': 'enabled', + 'variationType': 'boolean', + 'variationValue': true, + 'reason': 'TARGETING_MATCH', + 'doLog': false + }, + } + } + } + }), + 200); + }), + )); + addTearDown(owner.disable); + return owner; +} + +// Yield to the next event-loop turn so queued callback microtasks can run. +Future _waitForEventLoop() => Future.delayed(Duration.zero); +void main() { + for (final late in [false, true]) { + test( + '${late ? "late" : "early"} callback uses registration zone and routes async errors there', + () async { + final client = (await _owner()).sharedClient(); + if (late) await client.initialize(_context); + Object? observed; + final errors = []; + runZonedGuarded(() { + client.onFirstFlags((_) { + observed = Zone.current[#listenerZone]; + scheduleMicrotask(() => throw StateError('async listener error')); + throw StateError('isolated synchronous listener error'); + }); + }, (error, stack) => errors.add(error), + zoneValues: {#listenerZone: 'registration'}); + if (!late) { + await runZoned(() => client.initialize(_context), + zoneValues: {#listenerZone: 'initialization'}); + } + await _waitForEventLoop(); + expect(observed, 'registration'); + expect(errors, hasLength(1)); + expect(errors.single.toString(), contains('async listener error')); + }); + } + + test( + 'each early and late registration receives retained event asynchronously once', + () async { + final client = (await _owner()).sharedClient(); + final events = []; + client.onFirstFlags(events.add); + client.onFirstFlags(events.add); + expect(events, isEmpty); + await client.initialize(_context); + expect(events, hasLength(2)); + expect(events[0], same(events[1])); + await client.initialize(const FlagsEvaluationContext(targetingKey: 'next')); + client.onFirstFlags(events.add); + expect(events, hasLength(2)); + await _waitForEventLoop(); + expect(events, hasLength(3)); + expect(events.last, same(events.first)); + }); + test('cancel before install is per-registration and repeat-safe', () async { + final client = (await _owner()).sharedClient(); + var calls = 0; + void callback(FlagsClientEvent _) => calls++; + final cancel = client.onFirstFlags(callback); + client.onFirstFlags(callback); + cancel(); + cancel(); + await client.initialize(_context); + expect(calls, 1); + client.onFirstFlags(callback); + await _waitForEventLoop(); + expect(calls, 2); + }); + test('late replay uses one microtask and queued cancellation is idempotent', + () async { + final client = (await _owner()).sharedClient(); + await client.initialize(_context); + fakeAsync((async) { + final events = []; + final canceled = client.onFirstFlags(events.add); + expect(async.microtaskCount, 1); + expect(events, isEmpty); + canceled(); + canceled(); + async.flushMicrotasks(); + expect(events, isEmpty); + final completed = client.onFirstFlags(events.add); + expect(async.microtaskCount, 1); + async.flushMicrotasks(); + completed(); + completed(); + expect(events.single.flagsChanged, ['checkout.enabled']); + }); + }); + + test('pending duplicate functions are independent registrations', () async { + final client = (await _owner()).sharedClient(); + fakeAsync((async) { + var calls = 0; + void callback(FlagsClientEvent _) => calls++; + final first = client.onFirstFlags(callback); + final second = client.onFirstFlags(callback); + first(); + first(); + client.initialize(_context); + expect(calls, 0); + async.flushMicrotasks(); + expect(calls, 1); + second(); + async.flushMicrotasks(); + expect(calls, 1); + }); + }); + + test( + 'registration without initialization performs no request or notification', + () async { + var requests = 0; + final client = (await _owner(onRequest: () => requests++)).sharedClient(); + fakeAsync((async) { + var calls = 0; + final cancel = client.onFirstFlags((_) => calls++); + async.flushMicrotasks(); + async.elapse(const Duration(days: 1)); + expect(requests, 0); + expect(calls, 0); + cancel(); + cancel(); + }); + }); + + test( + 'canceled initialization with empty disk result sends no request or event', + () async { + late Completer disk; + var requests = 0; + final client = (await _owner( + store: _PendingStore(() => disk.future), + onRequest: () => requests++, + )) + .sharedClient(); + fakeAsync((async) { + var calls = 0; + var initialized = false; + disk = Completer(); + final cancel = client.onFirstFlags((_) => calls++); + client.initialize(_context).then((_) => initialized = true); + client.shutdown(); + disk.complete(null); + async.flushMicrotasks(); + expect(initialized, isTrue); + expect(requests, 0); + expect(calls, 0); + cancel(); + cancel(); + }); + }); + test('running callback can cancel another queued callback and itself', + () async { + final client = (await _owner()).sharedClient(); + var calls = 0; + late void Function() second; + late void Function() first; + first = client.onFirstFlags((_) { + first(); + second(); + calls++; + }); + second = client.onFirstFlags((_) => calls += 100); + await client.initialize(_context); + expect(calls, 1); + }); + for (final error in [Exception('app'), StateError('app'), Object()]) { + test( + 'nested registration is another microtask and ${error.runtimeType} is isolated', + () async { + final client = (await _owner()).sharedClient(); + final order = []; + client.onFirstFlags((_) { + order.add('first'); + client.onFirstFlags((_) => order.add('nested')); + expect(order, ['first']); + throw error; + }); + client.onFirstFlags((_) => order.add('second')); + await client.initialize(_context); + await _waitForEventLoop(); + expect(order, ['first', 'second', 'nested']); + }); + } + test('cancel all does not cancel initialize or erase retained event', + () async { + final client = (await _owner()).sharedClient(); + var canceledCalls = 0; + final cancel = client.onFirstFlags((_) => canceledCalls++); + cancel(); + await client.initialize(_context); + final delivered = Completer(); + client.onFirstFlags(delivered.complete); + expect((await delivered.future).flagsChanged, ['checkout.enabled']); + expect(canceledCalls, 0); + expect( + client + .getBooleanDetails(key: 'checkout.enabled', defaultValue: false) + .value, + isTrue); + }); + test( + 'reset and shutdown retain event but evaluations read current assignments', + () async { + final client = (await _owner()).sharedClient(); + await client.initialize(_context); + await client.reset(); + await client.shutdown(); + final delivered = Completer(); + client.onFirstFlags(delivered.complete); + expect(delivered.isCompleted, isFalse); + expect((await delivered.future).flagsChanged, ['checkout.enabled']); + expect( + client + .getBooleanDetails(key: 'checkout.enabled', defaultValue: false) + .error, + FlagEvaluationError.providerNotReady); + }); + test('noop never fabricates event and returns safe unregister', () async { + final owner = DatadogFlags(); + final client = owner.sharedClient(); + var calls = 0; + final cancel = client.onFirstFlags((_) => calls++); + await client.initialize(_context); + cancel(); + cancel(); + await _waitForEventLoop(); + expect(calls, 0); + }); +} + +class _PendingStore implements DatadogFlagsStore { + final Future Function() load; + _PendingStore(this.load); + @override + Future read(String name) => load(); + @override + Future write(String name, FlagsData data) async {} + @override + Future delete(String name) async {} +} diff --git a/packages/datadog_flags/test/flags_client_event_test.dart b/packages/datadog_flags/test/flags_client_event_test.dart new file mode 100644 index 000000000..3f6832af2 --- /dev/null +++ b/packages/datadog_flags/test/flags_client_event_test.dart @@ -0,0 +1,46 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. This product includes software +// developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import 'package:datadog_flags/datadog_flags.dart'; +import 'package:test/test.dart'; + +void main() { + test('omitted and explicitly null keys stay absent', () { + final omitted = FlagsClientEvent( + type: FlagsClientEventType.configurationChanged, + ); + final explicitNull = FlagsClientEvent( + type: FlagsClientEventType.configurationChanged, + flagsChanged: null, + ); + expect(omitted.flagsChanged, isNull); + expect(explicitNull.flagsChanged, isNull); + }); + + test('explicit empty keys remain present and immutable', () { + final keys = []; + final event = FlagsClientEvent( + type: FlagsClientEventType.configurationChanged, + flagsChanged: keys, + ); + keys.add('later'); + expect(event.flagsChanged, isNotNull); + expect(event.flagsChanged, isEmpty); + expect(() => event.flagsChanged!.add('mutation'), throwsUnsupportedError); + }); + + test('snapshots mutable keys without filtering or reordering', () { + final keys = ['second', 'first', 'first']; + final event = FlagsClientEvent( + type: FlagsClientEventType.configurationChanged, + flagsChanged: keys, + ); + keys[0] = 'replacement'; + keys.clear(); + expect(event.flagsChanged, ['second', 'first', 'first']); + expect(() => event.flagsChanged![0] = 'mutation', throwsUnsupportedError); + expect(() => event.flagsChanged!.clear(), throwsUnsupportedError); + }); +} diff --git a/packages/datadog_flags_flutter/README.md b/packages/datadog_flags_flutter/README.md index 6400412c1..901ab0f52 100644 --- a/packages/datadog_flags_flutter/README.md +++ b/packages/datadog_flags_flutter/README.md @@ -125,3 +125,23 @@ Pull requests are welcome. For more information, read the ## License [Apache License, v2.0](LICENSE) + +## First installed flags + +Register after obtaining a shared client to receive the keys from its first +accepted cache or network configuration. Late registrations receive the same +event, and callbacks run in a later microtask. Evaluating through this client +preserves RUM feature flag tracking. + +```dart +final client = DatadogSdk.instance.flags!.sharedClient(); +final unregister = client.onFirstFlags((event) { + debugPrint('First installed flags: ${event.flagsChanged}'); +}); +// Call unregister() in dispose when the notification is no longer needed. +``` + +If no usable configuration is installed, the callback remains pending until +you unregister it. See the [example app](example/lib/main.dart) for registration, +evaluation and widget disposal. Configure its `.env` using `melos generate_env` +and set `DD_CLIENT_TOKEN`, `DD_ENV`, and optionally `DD_APPLICATION_ID` for RUM. diff --git a/packages/datadog_flags_flutter/example/lib/main.dart b/packages/datadog_flags_flutter/example/lib/main.dart index 175614d35..1e0f29650 100644 --- a/packages/datadog_flags_flutter/example/lib/main.dart +++ b/packages/datadog_flags_flutter/example/lib/main.dart @@ -8,22 +8,19 @@ import 'dart:async'; import 'package:datadog_flags_flutter/datadog_flags_flutter.dart'; import 'package:datadog_flutter_plugin/datadog_flutter_plugin.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; -const _clientToken = String.fromEnvironment('DD_CLIENT_TOKEN'); -const _applicationId = String.fromEnvironment('DD_APPLICATION_ID'); -const _env = String.fromEnvironment('DD_ENV', defaultValue: 'dev'); -const _site = String.fromEnvironment('DD_SITE', defaultValue: 'us1'); -const _targetingKey = String.fromEnvironment( - 'DD_TARGETING_KEY', - defaultValue: 'example-user', -); -const _flagKey = String.fromEnvironment( - 'DD_FLAG_KEY', - defaultValue: 'checkout.enabled', -); +String get _clientToken => dotenv.get('DD_CLIENT_TOKEN', fallback: ''); +String get _applicationId => dotenv.get('DD_APPLICATION_ID', fallback: ''); +String get _env => dotenv.get('DD_ENV', fallback: 'dev'); +String get _site => dotenv.get('DD_SITE', fallback: 'us1'); +String get _targetingKey => + dotenv.get('DD_TARGETING_KEY', fallback: 'example-user'); +String get _flagKey => dotenv.get('DD_FLAG_KEY', fallback: 'checkout.enabled'); Future main() async { WidgetsFlutterBinding.ensureInitialized(); + await dotenv.load(); final isConfigured = _clientToken.isNotEmpty; if (isConfigured) { @@ -72,17 +69,36 @@ class FlagsExampleApp extends StatefulWidget { class _FlagsExampleAppState extends State { String _status = 'idle'; FlagDetails? _details; + FlagDetails? _firstDetails; + void Function()? _unregisterFirstFlags; @override void initState() { super.initState(); + final client = DatadogSdk.instance.flags?.sharedClient(); + if (widget.isConfigured && client != null) { + _unregisterFirstFlags = client.onFirstFlags((event) { + debugPrint('First installed flags: ${event.flagsChanged}'); + final details = client.getBooleanDetails( + key: _flagKey, + defaultValue: false, + ); + if (mounted) setState(() => _firstDetails = details); + }); + } unawaited(_evaluate()); } + @override + void dispose() { + _unregisterFirstFlags?.call(); + super.dispose(); + } + Future _evaluate() async { if (!widget.isConfigured) { setState(() { - _status = 'Set DD_CLIENT_TOKEN with --dart-define to evaluate flags.'; + _status = 'Set DD_CLIENT_TOKEN in .env to evaluate flags.'; }); return; } @@ -102,7 +118,7 @@ class _FlagsExampleAppState extends State { var status = 'ready'; try { await client.initialize( - const FlagsEvaluationContext(targetingKey: _targetingKey), + FlagsEvaluationContext(targetingKey: _targetingKey), ); } on FlagsInitializationTimeoutException catch (error) { status = 'using stored assignments or defaults: ${error.message}'; @@ -139,6 +155,9 @@ class _FlagsExampleAppState extends State { _InfoRow(label: 'Targeting key', value: _targetingKey), const Divider(height: 32), _InfoRow(label: 'Flag key', value: _flagKey), + _InfoRow( + label: 'First flags value', + value: _firstDetails?.value.toString() ?? '(waiting)'), _InfoRow( label: 'Value', value: details == null ? '(none)' : details.value.toString(), diff --git a/packages/datadog_flags_flutter/example/pubspec.yaml b/packages/datadog_flags_flutter/example/pubspec.yaml index 9bb4de05e..5909449ea 100644 --- a/packages/datadog_flags_flutter/example/pubspec.yaml +++ b/packages/datadog_flags_flutter/example/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: datadog_flags_flutter: path: .. datadog_flutter_plugin: ^3.4.0 + flutter_dotenv: ^5.0.2 dependency_overrides: datadog_flutter_plugin: @@ -24,3 +25,5 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - .env diff --git a/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart b/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart index 8bf11305b..ba95d5329 100644 --- a/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart +++ b/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart @@ -8,6 +8,8 @@ export 'package:datadog_flags/datadog_flags.dart' DatadogFlagsConfig, DatadogFlagsClient, DatadogFlagsConfiguration, + FlagsClientEvent, + FlagsClientEventType, DatadogFlagsSite, DatadogFlagsStore, FlagDetails, diff --git a/packages/datadog_flags_flutter/lib/src/datadog_flags_plugin.dart b/packages/datadog_flags_flutter/lib/src/datadog_flags_plugin.dart index ce1ee2802..d7d77e8cb 100644 --- a/packages/datadog_flags_flutter/lib/src/datadog_flags_plugin.dart +++ b/packages/datadog_flags_flutter/lib/src/datadog_flags_plugin.dart @@ -184,6 +184,28 @@ class DatadogFlutterFlagsClient implements DatadogFlagsClient { }) : _resolveDelegate = resolveDelegate, _addRumFeatureFlagEvaluation = addRumFeatureFlagEvaluation; + /// Forwards registration to the core client once it is available. + /// The core owns asynchronous delivery and registration-zone handling. + /// Cancellation works while the delegate is resolving and before delivery. + @override + void Function() onFirstFlags(void Function(FlagsClientEvent) callback) { + final registration = _FlutterFirstFlagsRegistration(callback); + unawaited(_forwardFirstFlags(registration)); + return registration.cancel; + } + + Future _forwardFirstFlags( + _FlutterFirstFlagsRegistration registration, + ) async { + try { + final delegate = await _delegateOrResolve(); + registration.forwardTo(delegate); + } catch (_) { + // Registration is best effort; initialization retains its own error path. + registration.cancel(); + } + } + @override Future initialize(FlagsEvaluationContext context) async { final delegate = await _delegateOrResolve(); @@ -321,3 +343,37 @@ DatadogFlagsSite? datadogFlagsSiteFor(DatadogSite site) { DatadogSite.us1Fed => null, }; } + +// Pending resolution and core delivery retain this record. Canceling clears +// its application callback even while the resolver is still pending. +class _FlutterFirstFlagsRegistration { + void Function(FlagsClientEvent)? _callback; + void Function()? _unregister; + + _FlutterFirstFlagsRegistration(this._callback); + + void forwardTo(DatadogFlagsClient delegate) { + if (_callback == null) return; + _unregister = delegate.onFirstFlags(_deliver); + } + + void cancel() { + _callback = null; + final unregister = _unregister; + _unregister = null; + try { + unregister?.call(); + } catch (_) { + // Cancellation must not throw into application cleanup. + } + } + + void _deliver(FlagsClientEvent event) { + final callback = _callback; + _callback = null; + _unregister = null; + // A conforming core invokes this once, asynchronously in the registration + // zone, and isolates synchronous listener errors. + callback?.call(event); + } +} diff --git a/packages/datadog_flags_flutter/pubspec.yaml b/packages/datadog_flags_flutter/pubspec.yaml index 95e3112ab..21e7b8585 100644 --- a/packages/datadog_flags_flutter/pubspec.yaml +++ b/packages/datadog_flags_flutter/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: meta: ^1.0.0 dev_dependencies: + fake_async: ^1.3.1 http: ^1.0.0 flutter_test: sdk: flutter diff --git a/packages/datadog_flags_flutter/test/first_flags_registration_test.dart b/packages/datadog_flags_flutter/test/first_flags_registration_test.dart new file mode 100644 index 000000000..6be5f4c5e --- /dev/null +++ b/packages/datadog_flags_flutter/test/first_flags_registration_test.dart @@ -0,0 +1,316 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. This product includes software +// developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import 'dart:async'; +import 'dart:convert'; +import 'package:datadog_flags/datadog_flags.dart'; +import 'package:datadog_flags_flutter/datadog_flags_flutter.dart'; +import 'package:datadog_flutter_plugin/datadog_flutter_plugin.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; +import 'helpers/first_flags_test_client.dart'; + +// Test fixture deliberately constructs an SDK-internal event. +// ignore: invalid_use_of_internal_member +final _event = FlagsClientEvent( + type: FlagsClientEventType.configurationChanged, + flagsChanged: ['checkout.enabled']); + +class _Sdk extends Mock implements DatadogSdk {} + +class _Rum extends Mock implements DatadogRum {} + +// Models the core's async, registration-zone and one-shot contract. +class _Delegate extends Fake implements DatadogFlagsClient { + int registrations = 0; + int initializations = 0; + int cancellations = 0; + bool throwRegistration = false; + bool throwCancel = false; + void Function(FlagsClientEvent)? listener; + + @override + void Function() onFirstFlags(void Function(FlagsClientEvent) callback) { + registrations++; + if (throwRegistration) throw StateError('registration failed'); + listener = Zone.current.bindUnaryCallback(callback); + return () { + cancellations++; + listener = null; + if (throwCancel) throw StateError('cancellation failed'); + }; + } + + void emit({void Function()? afterDelivery}) { + scheduleMicrotask(() { + final callback = listener; + listener = null; + try { + callback?.call(_event); + } catch (_) { + // The core isolates synchronous listener failures. + } + afterDelivery?.call(); + }); + } + + @override + Future initialize(FlagsEvaluationContext context) async { + initializations++; + } +} + +// Integration tests yield a turn; unit tests control microtasks explicitly. +Future _waitForEventLoop() => Future.delayed(Duration.zero); + +void main() { + test('cancel before resolver completes never registers with the core', () { + fakeAsync((async) { + final resolving = Completer(); + final core = _Delegate(); + final client = createFirstFlagsTestClient(() => resolving.future); + var calls = 0; + final cancel = client.onFirstFlags((_) => calls++); + cancel(); + cancel(); + resolving.complete(core); + async.flushMicrotasks(); + expect(core.registrations, 0); + expect(core.cancellations, 0); + expect(calls, 0); + }); + }); + + test('resolved registration waits without starting initialization', () { + fakeAsync((async) { + final resolving = Completer(); + final core = _Delegate(); + final client = createFirstFlagsTestClient(() => resolving.future); + var calls = 0; + final cancel = client.onFirstFlags((_) => calls++); + expect(core.registrations, 0); + resolving.complete(core); + async.flushMicrotasks(); + async.elapse(const Duration(days: 1)); + expect(core.registrations, 1); + expect(core.initializations, 0); + expect(calls, 0); + cancel(); + cancel(); + expect(core.cancellations, 1); + }); + }); + + test('cancel after core enqueue suppresses delivery and unregisters once', + () { + fakeAsync((async) { + final core = _Delegate(); + final client = createFirstFlagsTestClient(() async => core); + var calls = 0; + final cancel = client.onFirstFlags((_) => calls++); + async.flushMicrotasks(); + core.emit(); + expect(calls, 0); + cancel(); + cancel(); + async.flushMicrotasks(); + expect(core.cancellations, 1); + expect(core.listener, isNull); + expect(calls, 0); + }); + }); + + test('forwards within core delivery without another microtask', () { + fakeAsync((async) { + final core = _Delegate(); + final client = createFirstFlagsTestClient(() async => core); + final order = []; + late void Function() cancel; + cancel = client.onFirstFlags((_) { + order.add('app'); + cancel(); + }); + async.flushMicrotasks(); + core.emit(afterDelivery: () => order.add('core returned')); + expect(order, isEmpty); + async.flushMicrotasks(); + expect(order, ['app', 'core returned']); + cancel(); + expect(core.cancellations, 0); + }); + }); + + test('resolver and registration failures do not escape registration', () { + fakeAsync((async) { + final resolving = Completer(); + final client = createFirstFlagsTestClient(() => resolving.future); + var calls = 0; + final cancel = client.onFirstFlags((_) => calls++); + resolving.completeError(StateError('enable failed')); + async.flushMicrotasks(); + cancel(); + final core = _Delegate()..throwRegistration = true; + final failed = createFirstFlagsTestClient(() async => core); + final cancelFailed = failed.onFirstFlags((_) => calls++); + async.flushMicrotasks(); + cancelFailed(); + expect(calls, 0); + }); + }); + + test('unregister isolates delegate errors and remains idempotent', () { + fakeAsync((async) { + final core = _Delegate()..throwCancel = true; + final client = createFirstFlagsTestClient(() async => core); + final cancel = client.onFirstFlags((_) {}); + async.flushMicrotasks(); + expect(cancel, returnsNormally); + expect(cancel, returnsNormally); + expect(core.cancellations, 1); + }); + }); + + test('core isolates app throws and completion clears unregister', () { + fakeAsync((async) { + final core = _Delegate(); + final client = createFirstFlagsTestClient(() async => core); + var calls = 0; + final cancel = client.onFirstFlags((_) { + calls++; + throw StateError('app'); + }); + async.flushMicrotasks(); + core.emit(); + async.flushMicrotasks(); + cancel(); + expect(calls, 1); + expect(core.cancellations, 0); + }); + }); + + for (final late in [false, true]) { + test( + 'real core ${late ? "late" : "early"} delivery preserves registration zone and reentrancy', + () async { + final owner = DatadogFlags(); + await owner.enable( + configuration: DatadogFlagsConfiguration( + datadogConfig: const DatadogFlagsConfig( + clientToken: 'token', env: 'test', site: DatadogFlagsSite.us1), + httpClient: MockClient((_) async => + http.Response('{"data":{"attributes":{"flags":{}}}}', 200)), + )); + addTearDown(owner.disable); + final core = owner.sharedClient(); + if (late) await core.initialize(FlagsEvaluationContext.empty); + fakeAsync((async) { + final resolving = Completer(); + final client = createFirstFlagsTestClient(() => resolving.future); + final order = []; + final zones = []; + final errors = []; + final inventories = ?>[]; + var calls = 0; + void duplicate(FlagsClientEvent _) => calls++; + runZonedGuarded(() { + client.onFirstFlags((event) { + zones.add(Zone.current[#listener]); + inventories.add(event.flagsChanged); + order.add('first'); + client.onFirstFlags((_) => order.add('nested')); + order.add('first returned'); + scheduleMicrotask(() => throw StateError('async app error')); + throw StateError('sync app error'); + }); + final canceled = client.onFirstFlags(duplicate); + client.onFirstFlags(duplicate); + canceled(); + canceled(); + }, (error, stack) => errors.add(error), + zoneValues: {#listener: 'registration'}); + runZoned(() { + resolving.complete(core); + if (!late) core.initialize(FlagsEvaluationContext.empty); + }, zoneValues: {#listener: 'initializer'}); + expect(order, isEmpty); + async.flushMicrotasks(); + expect(order, ['first', 'first returned', 'nested']); + expect(zones, ['registration']); + expect(inventories.single, isEmpty); + expect(calls, 1); + expect(errors, hasLength(1)); + expect(errors.single.toString(), contains('async app error')); + }); + }); + } + + test('real core retained event binds wrapper before immediate evaluation', + () async { + final sdk = _Sdk(); + when(() => sdk.configuration).thenReturn(DatadogConfiguration( + clientToken: 'token', env: 'test', site: DatadogSite.us1)); + final rum = _Rum(); + when(() => sdk.rum).thenReturn(rum); + final owner = DatadogFlags(); + var requests = 0; + final plugin = DatadogFlagsPlugin(sdk, + flags: owner, rumIntegrationEnabled: true, flagsConfiguration: + DatadogFlagsConfiguration(httpClient: MockClient((_) async { + requests++; + return http.Response( + jsonEncode({ + 'data': { + 'attributes': { + 'flags': { + 'checkout.enabled': { + 'allocationKey': 'allocation', + 'variationKey': 'enabled', + 'variationType': 'boolean', + 'variationValue': true, + 'reason': 'TARGETING_MATCH', + 'doLog': false + }, + } + } + } + }), + 200); + }))); + addTearDown(owner.disable); + plugin.initialize(); + await plugin.ready; + await owner + .sharedClient() + .initialize(const FlagsEvaluationContext(targetingKey: 'user')); + final client = plugin.sharedClient(); + final events = []; + FlagDetails? details; + client.onFirstFlags((event) { + events.add(event); + details = client.getBooleanDetails( + key: 'checkout.enabled', defaultValue: false); + }); + expect(events, isEmpty); + await _waitForEventLoop(); + expect(events.single.flagsChanged, ['checkout.enabled']); + expect(details!.value, isTrue); + expect(details!.error, isNull); + expect(requests, 1); + verify(() => rum.addFeatureFlagEvaluation('checkout.enabled', 'enabled')) + .called(1); + final cancel = client.onFirstFlags(events.add); + cancel(); + await _waitForEventLoop(); + expect(events, hasLength(1)); + final replay = Completer(); + client.onFirstFlags(replay.complete); + expect(replay.isCompleted, isFalse); + expect(await replay.future, same(events.single)); + expect(requests, 1); + }); +} diff --git a/packages/datadog_flags_flutter/test/helpers/first_flags_test_client.dart b/packages/datadog_flags_flutter/test/helpers/first_flags_test_client.dart new file mode 100644 index 000000000..f83f852e1 --- /dev/null +++ b/packages/datadog_flags_flutter/test/helpers/first_flags_test_client.dart @@ -0,0 +1,15 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. This product includes software +// developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import 'package:datadog_flags_flutter/datadog_flags_flutter.dart'; + +DatadogFlutterFlagsClient createFirstFlagsTestClient( + Future Function() resolve, +) => + DatadogFlutterFlagsClient( + name: 'default', + resolveDelegate: resolve, + addRumFeatureFlagEvaluation: null, + ); From 37cc95606e6800afc06c88f4afb9ffb4fa83f8d3 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 14 Oct 2026 09:12:53 -0600 Subject: [PATCH 2/5] Refine documentation for onFirstFlags method Updated documentation for onFirstFlags method to clarify behavior. --- packages/datadog_flags/lib/src/flags_client.dart | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/datadog_flags/lib/src/flags_client.dart b/packages/datadog_flags/lib/src/flags_client.dart index 996f01b55..353071cc4 100644 --- a/packages/datadog_flags/lib/src/flags_client.dart +++ b/packages/datadog_flags/lib/src/flags_client.dart @@ -16,19 +16,9 @@ abstract interface class DatadogFlagsClient { /// Stable name assigned by [DatadogFlags.sharedClient]. String get name; - /// Calls [listener] once with the first accepted cache or network configuration. - /// The event contains its flag keys, including an empty list for an empty - /// configuration. Later registrations receive the same retained event. - /// If no usable configuration is installed, the callback remains pending. + /// Calls [listener] once with the first accepted cached or network configuration. /// - /// Delivery runs in a later microtask in the registration zone. The callback - /// can evaluate current flag values and start asynchronous work. Synchronous - /// exceptions are caught; the application handles errors from its async work. - /// Registration does not fetch flags or wait for initialization to complete. - /// - /// Returns an unregister function that prevents delivery until the callback - /// starts. Calling it repeatedly is safe. Reset preserves the retained event; - /// obtain a new shared client after SDK re-enable. + /// Returns an unregister function. void Function() onFirstFlags( void Function(FlagsClientEvent event) listener, ); From d02b649bcecbfc79d792a6e5089444855723167a Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 14 Oct 2026 09:17:20 -0600 Subject: [PATCH 3/5] more comment refinement Updated documentation for FlagsClientEvent constructor. --- packages/datadog_flags/lib/src/flags_client_event.dart | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/datadog_flags/lib/src/flags_client_event.dart b/packages/datadog_flags/lib/src/flags_client_event.dart index dd6792c97..8b796bcaf 100644 --- a/packages/datadog_flags/lib/src/flags_client_event.dart +++ b/packages/datadog_flags/lib/src/flags_client_event.dart @@ -28,11 +28,9 @@ final class FlagsClientEvent { /// supplied empty list. This value does not infer or evaluate any keys. final List? flagsChanged; - /// SDK-internal event construction; applications receive events from - /// [DatadogFlagsClient.onFirstFlags] rather than constructing them. - /// - /// Copies [flagsChanged] into an unmodifiable list when supplied. Later - /// changes to the source list cannot change this value. + /// SDK-internal event construction; copies [flagsChanged] into an + /// unmodifiable list when supplied. Later changes to the source list cannot + /// change this value. @internal FlagsClientEvent({required this.type, List? flagsChanged}) : flagsChanged = flagsChanged == null From 521cfa599bf96087d2d18abde8341c462651fc1a Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 14 Oct 2026 09:18:48 -0600 Subject: [PATCH 4/5] feat(flags): export a reusable client event listener type Use FlagsClientEventListener across core and Flutter callback signatures while preserving inline closure compatibility and unregister behaviour. Validated with 125 core tests, 23 Flutter tests, static analysis, formatting, and public-import checks for both packages. --- packages/datadog_flags/lib/datadog_flags.dart | 2 +- packages/datadog_flags/lib/src/default_flags_client.dart | 3 +-- packages/datadog_flags/lib/src/flags_client.dart | 2 +- packages/datadog_flags/lib/src/flags_client_event.dart | 3 +++ packages/datadog_flags/lib/src/flags_repository.dart | 6 +++--- packages/datadog_flags/lib/src/no_op_flags_client.dart | 4 +--- .../datadog_flags_flutter/lib/datadog_flags_flutter.dart | 1 + .../datadog_flags_flutter/lib/src/datadog_flags_plugin.dart | 4 ++-- 8 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/datadog_flags/lib/datadog_flags.dart b/packages/datadog_flags/lib/datadog_flags.dart index ab0c4f27e..3a4012a9c 100644 --- a/packages/datadog_flags/lib/datadog_flags.dart +++ b/packages/datadog_flags/lib/datadog_flags.dart @@ -21,4 +21,4 @@ export 'src/flags_store.dart' show DatadogFlagsStore, FlagsData; export 'src/evaluation_context.dart' show FlagsEvaluationContext; export 'src/flags_client_event.dart' - show FlagsClientEvent, FlagsClientEventType; + show FlagsClientEvent, FlagsClientEventType, FlagsClientEventListener; diff --git a/packages/datadog_flags/lib/src/default_flags_client.dart b/packages/datadog_flags/lib/src/default_flags_client.dart index 1c8c5da58..c276cbd31 100644 --- a/packages/datadog_flags/lib/src/default_flags_client.dart +++ b/packages/datadog_flags/lib/src/default_flags_client.dart @@ -31,8 +31,7 @@ class DefaultDatadogFlagsClient implements DatadogFlagsClient { _evaluationAggregator = evaluationAggregator; @override - void Function() onFirstFlags( - void Function(FlagsClientEvent event) listener) => + void Function() onFirstFlags(FlagsClientEventListener listener) => _repository.onFirstFlags(listener); @override diff --git a/packages/datadog_flags/lib/src/flags_client.dart b/packages/datadog_flags/lib/src/flags_client.dart index 353071cc4..1f8171c1d 100644 --- a/packages/datadog_flags/lib/src/flags_client.dart +++ b/packages/datadog_flags/lib/src/flags_client.dart @@ -20,7 +20,7 @@ abstract interface class DatadogFlagsClient { /// /// Returns an unregister function. void Function() onFirstFlags( - void Function(FlagsClientEvent event) listener, + FlagsClientEventListener listener, ); /// Fetches assignments for [context] and makes them available to evaluations. diff --git a/packages/datadog_flags/lib/src/flags_client_event.dart b/packages/datadog_flags/lib/src/flags_client_event.dart index 8b796bcaf..666da0965 100644 --- a/packages/datadog_flags/lib/src/flags_client_event.dart +++ b/packages/datadog_flags/lib/src/flags_client_event.dart @@ -5,6 +5,9 @@ import 'package:meta/meta.dart'; +/// Receives an event emitted by a flag client. +typedef FlagsClientEventListener = void Function(FlagsClientEvent event); + /// The kinds of notifications emitted by a flag client. enum FlagsClientEventType { /// A flag configuration has been installed. diff --git a/packages/datadog_flags/lib/src/flags_repository.dart b/packages/datadog_flags/lib/src/flags_repository.dart index 5d7833d45..9ae650aa1 100644 --- a/packages/datadog_flags/lib/src/flags_repository.dart +++ b/packages/datadog_flags/lib/src/flags_repository.dart @@ -60,7 +60,7 @@ class FlagsRepository { bool get isRestoredFromStore => _state?.restoredFromStore ?? false; - void Function() onFirstFlags(void Function(FlagsClientEvent) callback) { + void Function() onFirstFlags(FlagsClientEventListener callback) { final registration = _FirstFlagsRegistration(callback); final event = _firstFlagsEvent; if (event != null) { @@ -320,10 +320,10 @@ bool _jsonValuesMatch(Object? left, Object? right) { // Only this record owns the app callback. Queued work never copies it, so // cancellation releases its captures even when a microtask is already queued. class _FirstFlagsRegistration { - void Function(FlagsClientEvent)? _callback; + FlagsClientEventListener? _callback; void Function()? removePending; - _FirstFlagsRegistration(void Function(FlagsClientEvent) callback) + _FirstFlagsRegistration(FlagsClientEventListener callback) : _callback = Zone.current.bindUnaryCallback(callback); void cancel() { diff --git a/packages/datadog_flags/lib/src/no_op_flags_client.dart b/packages/datadog_flags/lib/src/no_op_flags_client.dart index 9fb380975..aaf6f8d46 100644 --- a/packages/datadog_flags/lib/src/no_op_flags_client.dart +++ b/packages/datadog_flags/lib/src/no_op_flags_client.dart @@ -15,9 +15,7 @@ class NoOpDatadogFlagsClient implements DatadogFlagsClient { NoOpDatadogFlagsClient({required this.name}); @override - void Function() onFirstFlags( - void Function(FlagsClientEvent event) listener) => - () {}; + void Function() onFirstFlags(FlagsClientEventListener listener) => () {}; @override Future initialize(FlagsEvaluationContext context) async {} diff --git a/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart b/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart index ba95d5329..152b28cef 100644 --- a/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart +++ b/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart @@ -10,6 +10,7 @@ export 'package:datadog_flags/datadog_flags.dart' DatadogFlagsConfiguration, FlagsClientEvent, FlagsClientEventType, + FlagsClientEventListener, DatadogFlagsSite, DatadogFlagsStore, FlagDetails, diff --git a/packages/datadog_flags_flutter/lib/src/datadog_flags_plugin.dart b/packages/datadog_flags_flutter/lib/src/datadog_flags_plugin.dart index d7d77e8cb..aeda8e749 100644 --- a/packages/datadog_flags_flutter/lib/src/datadog_flags_plugin.dart +++ b/packages/datadog_flags_flutter/lib/src/datadog_flags_plugin.dart @@ -188,7 +188,7 @@ class DatadogFlutterFlagsClient implements DatadogFlagsClient { /// The core owns asynchronous delivery and registration-zone handling. /// Cancellation works while the delegate is resolving and before delivery. @override - void Function() onFirstFlags(void Function(FlagsClientEvent) callback) { + void Function() onFirstFlags(FlagsClientEventListener callback) { final registration = _FlutterFirstFlagsRegistration(callback); unawaited(_forwardFirstFlags(registration)); return registration.cancel; @@ -347,7 +347,7 @@ DatadogFlagsSite? datadogFlagsSiteFor(DatadogSite site) { // Pending resolution and core delivery retain this record. Canceling clears // its application callback even while the resolver is still pending. class _FlutterFirstFlagsRegistration { - void Function(FlagsClientEvent)? _callback; + FlagsClientEventListener? _callback; void Function()? _unregister; _FlutterFirstFlagsRegistration(this._callback); From 442bcfdd1e5061ebec3385268fc69c311cdee47c Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 14 Oct 2026 09:21:32 -0600 Subject: [PATCH 5/5] test(flags): focus event coverage on defensive copying Retain the source-list mutation check and remove redundant container and collection assertions. First-flags behavioural coverage remains unchanged. Validation: 123 core tests pass; static analysis and formatting pass. --- .../test/flags_client_event_test.dart | 32 +++---------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/packages/datadog_flags/test/flags_client_event_test.dart b/packages/datadog_flags/test/flags_client_event_test.dart index 3f6832af2..e477a968b 100644 --- a/packages/datadog_flags/test/flags_client_event_test.dart +++ b/packages/datadog_flags/test/flags_client_event_test.dart @@ -7,40 +7,16 @@ import 'package:datadog_flags/datadog_flags.dart'; import 'package:test/test.dart'; void main() { - test('omitted and explicitly null keys stay absent', () { - final omitted = FlagsClientEvent( - type: FlagsClientEventType.configurationChanged, - ); - final explicitNull = FlagsClientEvent( - type: FlagsClientEventType.configurationChanged, - flagsChanged: null, - ); - expect(omitted.flagsChanged, isNull); - expect(explicitNull.flagsChanged, isNull); - }); - - test('explicit empty keys remain present and immutable', () { - final keys = []; + test('copies keys independently of subsequent source mutations', () { + final keys = ['second', 'first']; final event = FlagsClientEvent( type: FlagsClientEventType.configurationChanged, flagsChanged: keys, ); - keys.add('later'); - expect(event.flagsChanged, isNotNull); - expect(event.flagsChanged, isEmpty); - expect(() => event.flagsChanged!.add('mutation'), throwsUnsupportedError); - }); - test('snapshots mutable keys without filtering or reordering', () { - final keys = ['second', 'first', 'first']; - final event = FlagsClientEvent( - type: FlagsClientEventType.configurationChanged, - flagsChanged: keys, - ); keys[0] = 'replacement'; keys.clear(); - expect(event.flagsChanged, ['second', 'first', 'first']); - expect(() => event.flagsChanged![0] = 'mutation', throwsUnsupportedError); - expect(() => event.flagsChanged!.clear(), throwsUnsupportedError); + + expect(event.flagsChanged, ['second', 'first']); }); }