From 61900f691e0cd52ad0d50543e2439f1623c9e93e Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 2 Oct 2026 16:56:37 -0600 Subject: [PATCH 1/5] feat(flags): retain first flags events for cancellable callbacks --- packages/datadog_flags/CHANGELOG.md | 10 + packages/datadog_flags/README.md | 47 ++++ .../example/bin/typed_evaluation.dart | 13 +- packages/datadog_flags/example/pubspec.yaml | 2 + .../example/test/typed_evaluation_test.dart | 73 +++++ packages/datadog_flags/lib/datadog_flags.dart | 3 + .../lib/src/default_flags_client.dart | 5 + .../datadog_flags/lib/src/flags_client.dart | 22 +- .../lib/src/flags_client_event.dart | 36 +++ .../lib/src/flags_repository.dart | 63 +++++ .../lib/src/no_op_flags_client.dart | 5 + .../test/first_flags_callback_test.dart | 263 ++++++++++++++++++ .../test/first_flags_registration_test.dart | 168 +++++++++++ .../test/flags_client_event_test.dart | 60 ++++ .../test_vm/first_flags_capture_test.dart | 58 ++++ .../test_vm/helpers/force_gc.dart | 58 ++++ packages/datadog_flags_flutter/CHANGELOG.md | 10 + packages/datadog_flags_flutter/README.md | 37 +++ .../example/lib/main.dart | 22 ++ .../example/pubspec.yaml | 3 + .../test/first_flags_example_test.dart | 80 ++++++ .../lib/datadog_flags_flutter.dart | 2 + .../lib/src/datadog_flags_plugin.dart | 74 +++++ .../test/first_flags_registration_test.dart | 246 ++++++++++++++++ .../test/helpers/first_flags_test_client.dart | 15 + .../test_vm/first_flags_capture_test.dart | 69 +++++ .../test_vm/helpers/force_gc.dart | 58 ++++ 27 files changed, 1498 insertions(+), 4 deletions(-) create mode 100644 packages/datadog_flags/example/test/typed_evaluation_test.dart 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/test_vm/first_flags_capture_test.dart create mode 100644 packages/datadog_flags/test_vm/helpers/force_gc.dart create mode 100644 packages/datadog_flags_flutter/example/test/first_flags_example_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 create mode 100644 packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart create mode 100644 packages/datadog_flags_flutter/test_vm/helpers/force_gc.dart diff --git a/packages/datadog_flags/CHANGELOG.md b/packages/datadog_flags/CHANGELOG.md index 984b2549a..52a8e02e8 100644 --- a/packages/datadog_flags/CHANGELOG.md +++ b/packages/datadog_flags/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Breaking changes + +* Add `onFirstFlags` to `DatadogFlagsClient`. External implementations and test fakes must implement the new method, which returns an unregister function. Coordinate the core and Flutter integration release before publishing; this source change does not assign a release version. + +### Features + +* Retain the first accepted flag-installation event for early and late registrations. Every active registration is delivered once in a microtask; its unregister function suppresses delivery not yet started. + ## 1.1.0 * Add `DatadogFlagsConfiguration.initializationTimeout` for the first context initialization. The default is five seconds. Set it to `null`, zero, or a negative duration to disable it. diff --git a/packages/datadog_flags/README.md b/packages/datadog_flags/README.md index 0a59e9682..cdca5e2d7 100644 --- a/packages/datadog_flags/README.md +++ b/packages/datadog_flags/README.md @@ -363,3 +363,50 @@ types. Successful evaluation details report `CACHED` when the installed assignments were restored from the configured store, including an in-memory store. Once a network response replaces them, details report the response reason. + +## 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. +``` + +Each registration receives the retained first accepted cache/network event once, +including an empty installation. Missing, invalid, mismatched or rejected data +never fabricates completion. Multiple registrations are independent. Delivery +always runs in a microtask, including late registration; it does not wait for +initialization or persistence to complete and does not initialize the client. +Use the existing `initialize(context)` method and context/error behavior. + +`FlagsClientEvent` has only `type` and nullable `flagsChanged`. The implemented +type is `FlagsClientEventType.configurationChanged` (`CONFIGURATION_CHANGED`). +Supplied key lists are copied and unmodifiable; null and empty remain distinct. +First-install events contain all installed keys, including `[]`. Evaluations +read current assignments, not an assignment snapshot pinned to the event. + +The returned `void Function()` unregisters only its registration. It is +idempotent, releases pending callback captures and suppresses queued delivery +that has not started. It cannot interrupt a running callback or cancel +initialization. Reset does not clear or rearm the retained first event. +Synchronous callback exceptions are isolated; applications own any asynchronous +work and errors started by a void callback. Pending callbacks remain until an +accepted install or unregistration. Reacquire a shared client after SDK re-enable; +registrations do not migrate between core clients. + +See the actual [typed CLI example](example/bin/typed_evaluation.dart). This API +adds a method to the public client interface, so custom implementations/fakes +must be updated. The release version is coordinated separately. + +VM-only capture-release checks use actual garbage collection through the local +VM service: `dart test test_vm/first_flags_capture_test.dart`, or +`flutter test --enable-vmservice test_vm/first_flags_capture_test.dart`. 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/example/pubspec.yaml b/packages/datadog_flags/example/pubspec.yaml index 601f29f96..300dd0e6f 100644 --- a/packages/datadog_flags/example/pubspec.yaml +++ b/packages/datadog_flags/example/pubspec.yaml @@ -11,6 +11,8 @@ dependencies: path: .. dev_dependencies: + test: ^1.25.0 + http: ^1.0.0 lints: '>=5.0.0' executables: diff --git a/packages/datadog_flags/example/test/typed_evaluation_test.dart b/packages/datadog_flags/example/test/typed_evaluation_test.dart new file mode 100644 index 000000000..ebd32dc39 --- /dev/null +++ b/packages/datadog_flags/example/test/typed_evaluation_test.dart @@ -0,0 +1,73 @@ +// 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:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; + +import '../bin/typed_evaluation.dart' as example; + +class _Output implements Stdout { + final buffer = StringBuffer(); + @override + void writeln([Object? object = '']) => buffer.writeln(object); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + for (final empty in [false, true]) { + test( + 'actual CLI logs installed keys and evaluates its selected flag (empty=$empty)', + () async { + final output = _Output(); + await IOOverrides.runZoned( + () => http.runWithClient( + () => example.main(['--targeting-key', 'example-user']), + () => MockClient((_) async => _assignments(empty)), + ), + stdout: () => output, + ); + final lines = output.buffer.toString().split('\n'); + expect(lines.where((line) => line.startsWith('First installed flags:')), [ + empty + ? 'First installed flags: []' + : 'First installed flags: [checkout.enabled]' + ]); + // The callback and the existing post-initialization evaluation both run. + expect( + lines.where((line) => line == 'key: checkout.enabled'), hasLength(2)); + expect(lines.where((line) => line == 'value: ${!empty}'), hasLength(2)); + if (!empty) { + expect(lines.where((line) => line == 'variant: enabled'), hasLength(2)); + expect(lines.where((line) => line == 'error: (none)'), hasLength(2)); + } + }); + } +} + +http.Response _assignments(bool empty) => http.Response( + jsonEncode({ + 'data': { + 'attributes': { + 'flags': empty + ? {} + : { + 'checkout.enabled': { + 'allocationKey': 'allocation', + 'variationKey': 'enabled', + 'variationType': 'boolean', + 'variationValue': true, + 'reason': 'TARGETING_MATCH', + 'doLog': false + }, + } + } + } + }), + 200); 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..549c7e277 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,10 @@ class DefaultDatadogFlagsClient implements DatadogFlagsClient { _exposureLogger = exposureLogger, _evaluationAggregator = evaluationAggregator; + @override + void Function() onFirstFlags(void Function(FlagsClientEvent) callback) => + _repository.onFirstFlags(callback); + @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..a58d4e21f 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,25 @@ abstract interface class DatadogFlagsClient { /// Stable name assigned by [DatadogFlags.sharedClient]. String get name; + /// Registers [callback] for this client's first accepted cache or network + /// installation, including an empty configuration. Every registration receives + /// the retained first event once, even if registered after later updates. + /// Missing, invalid or rejected data does not complete this notification. + /// + /// Delivery always runs in a microtask, including late registrations. It does + /// not wait for initialization or persistence to complete. Evaluations in the + /// callback read current assignments, not a snapshot pinned to the event. + /// Synchronous callback exceptions are isolated; asynchronous work and errors + /// started by the callback belong to the application. + /// + /// Returns an idempotent unregister function. It releases the callback and + /// suppresses delivery that has not started, including an already queued + /// microtask. It cannot interrupt a running callback, clear the retained event + /// or cancel initialization. Reset does not rearm or erase the first event. + /// Pending callbacks remain until installation or explicit unregistration. + /// Reacquire a shared client after SDK re-enable; registrations do not migrate. + void Function() onFirstFlags(void Function(FlagsClientEvent) callback); + /// Fetches assignments for [context] and makes them available to evaluations. /// /// The first call completes when initialization finishes or the configured @@ -26,7 +46,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..331b11b30 --- /dev/null +++ b/packages/datadog_flags/lib/src/flags_client_event.dart @@ -0,0 +1,36 @@ +// 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'; + +/// Implemented flag client event types. Declaring a value does not emit it. +enum FlagsClientEventType { + 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; + + /// A snapshot of the supplied flag keys. + /// + /// `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; + + /// Creates an event, copying [flagsChanged] into an unmodifiable list when + /// supplied. Later changes to the caller's list cannot change this value. + 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..d24622054 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,33 @@ 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(this._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..655f77a13 100644 --- a/packages/datadog_flags/lib/src/no_op_flags_client.dart +++ b/packages/datadog_flags/lib/src/no_op_flags_client.dart @@ -6,6 +6,7 @@ import 'evaluation_context.dart'; import 'flags_client.dart'; import 'flags_error.dart'; +import 'flags_client_event.dart'; class NoOpDatadogFlagsClient implements DatadogFlagsClient { @override @@ -13,6 +14,10 @@ class NoOpDatadogFlagsClient implements DatadogFlagsClient { const NoOpDatadogFlagsClient({required this.name}); + @override + void Function() onFirstFlags(void Function(FlagsClientEvent) callback) => + () {}; + @override Future initialize(FlagsEvaluationContext context) async {} 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..c9b038f13 --- /dev/null +++ b/packages/datadog_flags/test/first_flags_callback_test.dart @@ -0,0 +1,263 @@ +// 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 events = []; + final flags = await _owner( + callback: events.add, + store: fromCache ? _Store(() async => _cached(empty: true)) : null, + request: (_) async => _response({})); + await flags.sharedClient().initialize(_context); + expect(events.single.type, FlagsClientEventType.configurationChanged); + expect(events.single.flagsChanged, isEmpty); + }); + } + + 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']); + }); + } + + test('failed initialization without installation does not notify', () async { + final events = []; + final flags = await _owner( + callback: events.add, + store: _Store(() async => throw FormatException('invalid cache')), + request: (_) async => throw StateError('offline')); + await flags.sharedClient().initialize(_context); + expect(events, isEmpty); + }); + + 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); + }); + + 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..5b3cacc74 --- /dev/null +++ b/packages/datadog_flags/test/first_flags_registration_test.dart @@ -0,0 +1,168 @@ +// 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: 'user'); +Future _owner() async { + final owner = DatadogFlags(); + await owner.enable( + configuration: DatadogFlagsConfiguration( + datadogConfig: const DatadogFlagsConfig( + clientToken: 'token', env: 'test', site: DatadogFlagsSite.us1), + httpClient: MockClient((_) async => 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; +} + +Future _flush() => Future.delayed(Duration.zero); +void main() { + 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 _flush(); + 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 _flush(); + expect(calls, 2); + }); + test('cancel queued late delivery and after completed delivery', () async { + final client = (await _owner()).sharedClient(); + await client.initialize(_context); + var calls = 0; + final cancel = client.onFirstFlags((_) => calls++); + expect(calls, 0); + cancel(); + await _flush(); + expect(calls, 0); + final after = client.onFirstFlags((_) => calls++); + await _flush(); + after(); + after(); + await _flush(); + expect(calls, 1); + }); + 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); + }); + test('nested registration is another microtask and exceptions are isolated', + () async { + final client = (await _owner()).sharedClient(); + final order = []; + client.onFirstFlags((_) { + order.add('first'); + client.onFirstFlags((_) => order.add('nested')); + expect(order, ['first']); + throw StateError('app'); + }); + client.onFirstFlags((_) => order.add('second')); + await client.initialize(_context); + await _flush(); + 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 _flush(); + expect(calls, 0); + }); +} 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..931d28796 --- /dev/null +++ b/packages/datadog_flags/test/flags_client_event_test.dart @@ -0,0 +1,60 @@ +// 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('only implemented event type maps to CONFIGURATION_CHANGED', () { + expect(FlagsClientEventType.values, + [FlagsClientEventType.configurationChanged]); + expect(FlagsClientEventType.configurationChanged.code, + 'CONFIGURATION_CHANGED'); + }); + + test('retains the required event type', () { + final event = + FlagsClientEvent(type: FlagsClientEventType.configurationChanged); + expect(event.type, FlagsClientEventType.configurationChanged); + expect(event.flagsChanged, isNull); + }); + + 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/test_vm/first_flags_capture_test.dart b/packages/datadog_flags/test_vm/first_flags_capture_test.dart new file mode 100644 index 000000000..5bdadcb26 --- /dev/null +++ b/packages/datadog_flags/test_vm/first_flags_capture_test.dart @@ -0,0 +1,58 @@ +// 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. + +@TestOn('vm') +library; + +import 'dart:async'; +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 'helpers/force_gc.dart'; + +@pragma('vm:never-inline') +(WeakReference, void Function()) register(DatadogFlagsClient client) { + final capture = Capture(); + final reference = WeakReference(capture); + final unregister = client.onFirstFlags((_) => capture.use()); + return (reference, unregister); +} + +void main() { + for (final queued in [false, true]) { + test( + 'cancel releases ${queued ? 'queued' : 'pending'} captures while token remains live', + () 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 client = owner.sharedClient(); + if (queued) await client.initialize(FlagsEvaluationContext.empty); + final held = []; + final registration = runZoned(() => register(client), zoneSpecification: + ZoneSpecification(scheduleMicrotask: (self, parent, zone, action) { + held.add(() => zone.run(action)); + })); + expect(held, hasLength(queued ? 1 : 0)); + await collectGarbage(); + expect(hasTarget(registration.$1), isTrue); + registration.$2(); + await collectGarbage(); + expect(hasTarget(registration.$1), isFalse); + registration.$2(); + // The queued closure is still retained during collection above. + for (final action in held) { + action(); + } + }); + } +} diff --git a/packages/datadog_flags/test_vm/helpers/force_gc.dart b/packages/datadog_flags/test_vm/helpers/force_gc.dart new file mode 100644 index 000000000..4edc8d3dd --- /dev/null +++ b/packages/datadog_flags/test_vm/helpers/force_gc.dart @@ -0,0 +1,58 @@ +// 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 'dart:developer' as developer; +import 'dart:io'; +import 'dart:isolate'; + +// VM-only capture-release proof. Request a full collection through the VM +// service rather than relying on allocation pressure or timing. +Future collectGarbage() async { + var info = await developer.Service.getInfo(); + info = info.serverUri == null + ? await developer.Service.controlWebServer( + enable: true, silenceOutput: true) + : info; + final uri = info.serverUri!; + final socket = await WebSocket.connect( + uri.replace(scheme: 'ws', path: '${uri.path}ws').toString()); + final responses = StreamIterator(socket); + try { + socket.add(jsonEncode({ + 'jsonrpc': '2.0', + 'id': 'gc', + 'method': 'getAllocationProfile', + 'params': { + 'isolateId': developer.Service.getIsolateId(Isolate.current), + 'gc': true + } + })); + while (await responses.moveNext()) { + final response = + jsonDecode(responses.current as String) as Map; + if (response['id'] != 'gc') continue; + if (response.containsKey('error')) { + throw StateError('${response['error']}'); + } + return; + } + throw StateError('VM service closed before GC response'); + } finally { + await responses.cancel(); + await socket.close(); + } +} + +class Capture { + int calls = 0; + void use() { + calls++; + } +} + +@pragma('vm:never-inline') +bool hasTarget(WeakReference reference) => reference.target != null; diff --git a/packages/datadog_flags_flutter/CHANGELOG.md b/packages/datadog_flags_flutter/CHANGELOG.md index 833324336..1157f7195 100644 --- a/packages/datadog_flags_flutter/CHANGELOG.md +++ b/packages/datadog_flags_flutter/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Breaking changes + +* Add `onFirstFlags` to `DatadogFlagsClient`. External implementations and test fakes must implement the new method, which returns an unregister function. Coordinate the core and Flutter integration release before publishing; this source change does not assign a release version. + +### Features + +* Retain the first accepted flag-installation event for early and late registrations. Every active registration is delivered once in a microtask; its unregister function suppresses delivery not yet started. + ## 1.1.0 * Forward `DatadogFlagsConfiguration.initializationTimeout` to the core SDK. The first context initialization has a five-second timeout by default. Set it to `null`, zero, or a negative duration to disable it. diff --git a/packages/datadog_flags_flutter/README.md b/packages/datadog_flags_flutter/README.md index e1e91ab38..b3a13e764 100644 --- a/packages/datadog_flags_flutter/README.md +++ b/packages/datadog_flags_flutter/README.md @@ -125,3 +125,40 @@ Pull requests are welcome. For more information, read the Successful evaluation details report `CACHED` when the installed assignments were restored from the configured store, including an in-memory store. Once a network response replaces them, details report the response reason. + +## First installed flags + +Call `client.onFirstFlags((event) { ... })` after obtaining a shared client. It +returns an ordinary unregister function; there is no subscription object. The +callback receives only `FlagsClientEvent` and always runs in a microtask, +including when the first event is already retained. The wrapper resolves its +existing core delegate before forwarding registration, without initializing a +context or starting an assignment fetch. Callback evaluations use the existing +RUM-integrated wrapper and current assignments. + +Unregister immediately releases the app callback and prevents delivery not yet +started, even while the delegate is resolving or a callback microtask is queued. +After forwarding, unregister invokes the core cancellation function. Each +registration forwards once to its resolved core; resolver failure does not +fabricate success or retry. Reacquire `sharedClient` after SDK re-enable; old +wrappers have no new lifecycle guarantee and registrations do not migrate. + +The [actual example app](example/lib/main.dart) registers once in `initState`, +logs `event.flagsChanged`, evaluates its existing `DD_FLAG_KEY`, displays the +first result, and unregisters in `dispose`. Its initialization/context flow +remains explicit. Run fixture app tests from `example` with +`flutter test --dart-define=DD_CLIENT_TOKEN=test-token`. They use fixture HTTP +and the SDK's no-op native platform, not a live service or device. + +Run the VM-only capture-release proof with +`flutter test --enable-vmservice test_vm/first_flags_capture_test.dart`. + +### Coordinated release requirement + +Adding the method breaks external `implements DatadogFlagsClient` classes and +fakes. The repository releaser assigns an explicit release version; this change +does not select one. Before publishing the integration, publish the coordinated +core containing this API and update its minimum dependency accordingly. The +current `^1.1.0` constraint alone does not ensure this method exists. Local path +overrides validate the companion source only and are not registry compatibility +proof. No package publication is part of this change. diff --git a/packages/datadog_flags_flutter/example/lib/main.dart b/packages/datadog_flags_flutter/example/lib/main.dart index 175614d35..244659bd3 100644 --- a/packages/datadog_flags_flutter/example/lib/main.dart +++ b/packages/datadog_flags_flutter/example/lib/main.dart @@ -72,13 +72,32 @@ 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(() { @@ -139,6 +158,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..5f3f401fa 100644 --- a/packages/datadog_flags_flutter/example/pubspec.yaml +++ b/packages/datadog_flags_flutter/example/pubspec.yaml @@ -20,6 +20,9 @@ dependency_overrides: path: ../../datadog_flags dev_dependencies: + flutter_test: + sdk: flutter + http: ^1.0.0 flutter_lints: ">=1.0.0" flutter: diff --git a/packages/datadog_flags_flutter/example/test/first_flags_example_test.dart b/packages/datadog_flags_flutter/example/test/first_flags_example_test.dart new file mode 100644 index 000000000..292bfff11 --- /dev/null +++ b/packages/datadog_flags_flutter/example/test/first_flags_example_test.dart @@ -0,0 +1,80 @@ +// 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:convert'; + +import 'package:datadog_flutter_plugin/datadog_flutter_plugin.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:datadog_flags_flutter_example/main.dart' as example; + +void main() { + for (final empty in [false, true]) { + testWidgets( + 'actual app logs keys and renders callback evaluation (empty=$empty)', + (tester) async { + expect(const String.fromEnvironment('DD_CLIENT_TOKEN'), isNotEmpty, + reason: 'Run with --dart-define=DD_CLIENT_TOKEN=test-token'); + DatadogSdk.initializeForTesting(); + final messages = []; + final originalDebugPrint = debugPrint; + debugPrint = (message, {wrapWidth}) => messages.add(message); + try { + await http.runWithClient( + () => example.main(), + () => MockClient((_) async => _assignments(empty)), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect( + messages.where( + (line) => line?.startsWith('First installed flags:') ?? false), + [ + empty + ? 'First installed flags: []' + : 'First installed flags: [checkout.enabled]' + ]); + expect(find.text('First flags value'), findsOneWidget); + expect(find.text('${!empty}'), findsNWidgets(2)); + expect(find.text('ready'), findsOneWidget); + await tester.ensureVisible(find.text('Evaluate flag')); + await tester.tap(find.text('Evaluate flag')); + await tester.pumpAndSettle(); + expect( + messages.where( + (line) => line?.startsWith('First installed flags:') ?? false), + hasLength(1)); + await tester.pumpWidget(const SizedBox.shrink()); + } finally { + debugPrint = originalDebugPrint; + await DatadogSdk.instance.flushAndDeinitialize(); + } + }, skip: const String.fromEnvironment('DD_CLIENT_TOKEN').isEmpty); + } +} + +http.Response _assignments(bool empty) => http.Response( + jsonEncode({ + 'data': { + 'attributes': { + 'flags': empty + ? {} + : { + 'checkout.enabled': { + 'allocationKey': 'allocation', + 'variationKey': 'enabled', + 'variationType': 'boolean', + 'variationValue': true, + 'reason': 'TARGETING_MATCH', + 'doLog': false + }, + } + } + } + }), + 200); 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..0cb5db234 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,31 @@ class DatadogFlutterFlagsClient implements DatadogFlagsClient { }) : _resolveDelegate = resolveDelegate, _addRumFeatureFlagEvaluation = addRumFeatureFlagEvaluation; + /// Always delivers in a microtask after binding the existing core delegate. + /// Cancellation works immediately, including while that delegate is resolving. + /// Each registration forwards once to its resolved core; there is no retry or + /// migration across re-enable. Reacquire a shared client after re-enable. + @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(); + if (!registration.isActive) return; + final unregister = delegate.onFirstFlags(registration.schedule); + registration.attach(unregister); + } 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 +346,52 @@ DatadogFlagsSite? datadogFlagsSiteFor(DatadogSite site) { DatadogSite.us1Fed => null, }; } + +// The resolver and downstream callback capture this record, never the app +// callback directly. Terminal transitions release both callback and token. +class _FlutterFirstFlagsRegistration { + void Function(FlagsClientEvent)? _callback; + void Function()? _unregister; + + _FlutterFirstFlagsRegistration(this._callback); + + bool get isActive => _callback != null; + + void attach(void Function() unregister) { + if (isActive) { + _unregister = unregister; + } else { + _tryUnregister(unregister); + } + } + + void cancel() { + _callback = null; + final unregister = _unregister; + _unregister = null; + if (unregister != null) _tryUnregister(unregister); + } + + void schedule(FlagsClientEvent event) { + if (!isActive) return; + scheduleMicrotask(() { + final callback = _callback; + _callback = null; + _unregister = null; + if (callback == null) return; + try { + callback(event); + } catch (_) { + // Keep app errors out of the core delivery and SDK-owned Future chains. + } + }); + } + + static void _tryUnregister(void Function() unregister) { + try { + unregister(); + } catch (_) { + // A custom delegate must not turn cancellation into an unhandled failure. + } + } +} 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..63070a03f --- /dev/null +++ b/packages/datadog_flags_flutter/test/first_flags_registration_test.dart @@ -0,0 +1,246 @@ +// 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:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; +import 'helpers/first_flags_test_client.dart'; + +final _event = FlagsClientEvent( + type: FlagsClientEventType.configurationChanged, + flagsChanged: ['checkout.enabled']); + +class _Sdk extends Mock implements DatadogSdk {} + +class _Rum extends Mock implements DatadogRum {} + +class _Delegate extends Fake implements DatadogFlagsClient { + int registrations = 0; + int initializations = 0; + int cancellations = 0; + void Function(FlagsClientEvent)? listener; + void Function()? duringRegistration; + bool throwCancel = false; + @override + void Function() onFirstFlags(void Function(FlagsClientEvent) callback) { + registrations++; + listener = callback; + duringRegistration?.call(); + return () { + cancellations++; + listener = null; + if (throwCancel) throw StateError('custom'); + }; + } + + @override + Future initialize(FlagsEvaluationContext context) async { + initializations++; + } + + @override + Future shutdown() async {} +} + +Future _flush() => Future.delayed(Duration.zero); + +void main() { + test('cancel while resolving releases registration without forwarding', + () 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); + await _flush(); + expect(core.registrations, 0); + expect(calls, 0); + }); + test('resolver failure is contained and initialization keeps original error', + () async { + final resolving = Completer(); + final client = createFirstFlagsTestClient(() => resolving.future); + var calls = 0; + final cancel = client.onFirstFlags((_) => calls++); + resolving.completeError(StateError('enable failed')); + await _flush(); + cancel(); + await expectLater( + client.initialize(FlagsEvaluationContext.empty), throwsStateError); + expect(calls, 0); + }); + test( + 'cancel after forwarding unregisters once and suppresses queued app work', + () async { + final core = _Delegate(); + final client = createFirstFlagsTestClient(() async => core); + var calls = 0; + final cancel = client.onFirstFlags((_) => calls++); + await _flush(); + core.listener!(_event); + expect(calls, 0); + cancel(); + cancel(); + await _flush(); + expect(core.cancellations, 1); + expect(core.listener, isNull); + expect(calls, 0); + }); + test( + 'custom synchronous delivery remains microtask and duplicates do not redeliver', + () async { + final core = _Delegate(); + final client = createFirstFlagsTestClient(() async => core); + var calls = 0; + var duringCalls = -1; + core.duringRegistration = () { + core.listener!(_event); + core.listener!(_event); + duringCalls = calls; + }; + final cancel = client.onFirstFlags((_) => calls++); + expect(calls, 0); + await _flush(); + expect(calls, 1); + expect(duringCalls, 0); + cancel(); + cancel(); + expect(core.cancellations, 0); + }); + test('cancel during custom registration cancels token obtained afterward', + () async { + final core = _Delegate()..throwCancel = true; + final client = createFirstFlagsTestClient(() async => core); + var calls = 0; + late void Function() cancel; + core.duringRegistration = () { + core.listener!(_event); + cancel(); + }; + cancel = client.onFirstFlags((_) => calls++); + await _flush(); + cancel(); + expect(core.cancellations, 1); + expect(calls, 0); + }); + test('callback exception does not produce failed SDK Future', () async { + final core = _Delegate(); + final client = createFirstFlagsTestClient(() async => core); + client.onFirstFlags((_) => throw StateError('app')); + await _flush(); + core.listener!(_event); + await _flush(); + }); + test('shutdown overlap forwards once to resolved core without migration', + () async { + final resolving = Completer(); + final coreA = _Delegate(); + final coreB = _Delegate(); + var resolutions = 0; + final client = createFirstFlagsTestClient(() { + resolutions++; + return resolving.future; + }); + var calls = 0; + final cancel = client.onFirstFlags((_) => calls++); + await client.shutdown(); + resolving.complete(coreA); + await _flush(); + expect(coreA.registrations, 1); + expect(coreB.registrations, 0); + expect(resolutions, 1); + coreA.listener!(_event); + await _flush(); + expect(calls, 1); + cancel(); + final replacement = createFirstFlagsTestClient(() async => coreB); + final cancelB = replacement.onFirstFlags((_) {}); + await _flush(); + expect(coreB.registrations, 1); + cancelB(); + }); + 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 _flush(); + 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 _flush(); + expect(events, hasLength(1)); + }); + test('registering before plugin readiness does not initialize flags', + () async { + final resolving = Completer(); + final core = _Delegate(); + final client = createFirstFlagsTestClient(() => resolving.future); + var calls = 0; + client.onFirstFlags((_) => calls++); + expect(core.registrations, 0); + resolving.complete(core); + await _flush(); + expect(core.registrations, 1); + expect(core.initializations, 0); + expect(calls, 0); + }); +} 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, + ); diff --git a/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart b/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart new file mode 100644 index 000000000..33a02a971 --- /dev/null +++ b/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart @@ -0,0 +1,69 @@ +// 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 'package:datadog_flags/datadog_flags.dart'; +import 'package:datadog_flags_flutter/datadog_flags_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'helpers/force_gc.dart'; +import '../test/helpers/first_flags_test_client.dart'; + +@pragma('vm:never-inline') +(WeakReference, void Function()) register( + DatadogFlutterFlagsClient client) { + final capture = Capture(); + final reference = WeakReference(capture); + return (reference, client.onFirstFlags((_) => capture.use())); +} + +class _Delegate extends Fake implements DatadogFlagsClient { + void Function(FlagsClientEvent)? listener; + @override + void Function() onFirstFlags(void Function(FlagsClientEvent) callback) { + listener = callback; + return () => listener = null; + } +} + +void main() { + test('cancel releases forwarded capture while app microtask remains queued', + () async { + final core = _Delegate(); + final client = createFirstFlagsTestClient(() async => core); + final registration = register(client); + await Future.delayed(Duration.zero); + final held = []; + runZoned( + () => core.listener!( + FlagsClientEvent(type: FlagsClientEventType.configurationChanged)), + zoneSpecification: + ZoneSpecification(scheduleMicrotask: (self, parent, zone, action) { + held.add(() => zone.run(action)); + })); + expect(held, hasLength(1)); + await collectGarbage(); + expect(hasTarget(registration.$1), isTrue); + registration.$2(); + await collectGarbage(); + expect(hasTarget(registration.$1), isFalse); + expect(core.listener, isNull); + held.single(); + }); + + test('cancel releases app capture while delegate Future remains pending', + () async { + final resolving = Completer(); + final client = createFirstFlagsTestClient(() => resolving.future); + final registration = register(client); + await collectGarbage(); + expect(hasTarget(registration.$1), isTrue); + registration.$2(); + await collectGarbage(); + expect(hasTarget(registration.$1), isFalse); + registration.$2(); + resolving.complete(DatadogFlags().sharedClient()); + await Future.delayed(Duration.zero); + }); +} diff --git a/packages/datadog_flags_flutter/test_vm/helpers/force_gc.dart b/packages/datadog_flags_flutter/test_vm/helpers/force_gc.dart new file mode 100644 index 000000000..4edc8d3dd --- /dev/null +++ b/packages/datadog_flags_flutter/test_vm/helpers/force_gc.dart @@ -0,0 +1,58 @@ +// 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 'dart:developer' as developer; +import 'dart:io'; +import 'dart:isolate'; + +// VM-only capture-release proof. Request a full collection through the VM +// service rather than relying on allocation pressure or timing. +Future collectGarbage() async { + var info = await developer.Service.getInfo(); + info = info.serverUri == null + ? await developer.Service.controlWebServer( + enable: true, silenceOutput: true) + : info; + final uri = info.serverUri!; + final socket = await WebSocket.connect( + uri.replace(scheme: 'ws', path: '${uri.path}ws').toString()); + final responses = StreamIterator(socket); + try { + socket.add(jsonEncode({ + 'jsonrpc': '2.0', + 'id': 'gc', + 'method': 'getAllocationProfile', + 'params': { + 'isolateId': developer.Service.getIsolateId(Isolate.current), + 'gc': true + } + })); + while (await responses.moveNext()) { + final response = + jsonDecode(responses.current as String) as Map; + if (response['id'] != 'gc') continue; + if (response.containsKey('error')) { + throw StateError('${response['error']}'); + } + return; + } + throw StateError('VM service closed before GC response'); + } finally { + await responses.cancel(); + await socket.close(); + } +} + +class Capture { + int calls = 0; + void use() { + calls++; + } +} + +@pragma('vm:never-inline') +bool hasTarget(WeakReference reference) => reference.target != null; From b9dac5d08c0a7f9e0ecade609897175378ea8242 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 5 Oct 2026 12:15:23 -0600 Subject: [PATCH 2/5] feat(flags): expose first flags through an events facade --- packages/datadog_flags/CHANGELOG.md | 6 +- packages/datadog_flags/README.md | 16 +++- .../example/bin/typed_evaluation.dart | 2 +- packages/datadog_flags/lib/datadog_flags.dart | 3 + .../lib/datadog_flags_internal.dart | 8 ++ .../lib/src/default_flags_client.dart | 10 +-- .../datadog_flags/lib/src/flags_client.dart | 20 ----- .../lib/src/flags_event_registry.dart | 34 ++++++++ .../datadog_flags/lib/src/flags_events.dart | 52 +++++++++++ .../lib/src/no_op_flags_client.dart | 12 +-- .../test/first_flags_callback_test.dart | 4 +- .../test/first_flags_registration_test.dart | 34 ++++---- .../test/flags_events_compatibility_test.dart | 86 +++++++++++++++++++ .../test_vm/first_flags_capture_test.dart | 32 ++++++- .../test_vm/helpers/force_gc.dart | 3 +- packages/datadog_flags_flutter/CHANGELOG.md | 6 +- packages/datadog_flags_flutter/README.md | 14 ++- .../example/lib/main.dart | 2 +- .../lib/datadog_flags_flutter.dart | 2 + .../lib/src/datadog_flags_plugin.dart | 10 ++- .../test/first_flags_registration_test.dart | 29 ++++--- .../test/flags_events_compatibility_test.dart | 50 +++++++++++ .../test_vm/first_flags_capture_test.dart | 39 ++++++++- .../test_vm/helpers/force_gc.dart | 3 +- 24 files changed, 387 insertions(+), 90 deletions(-) create mode 100644 packages/datadog_flags/lib/datadog_flags_internal.dart create mode 100644 packages/datadog_flags/lib/src/flags_event_registry.dart create mode 100644 packages/datadog_flags/lib/src/flags_events.dart create mode 100644 packages/datadog_flags/test/flags_events_compatibility_test.dart create mode 100644 packages/datadog_flags_flutter/test/flags_events_compatibility_test.dart diff --git a/packages/datadog_flags/CHANGELOG.md b/packages/datadog_flags/CHANGELOG.md index 52a8e02e8..0fafce17f 100644 --- a/packages/datadog_flags/CHANGELOG.md +++ b/packages/datadog_flags/CHANGELOG.md @@ -2,12 +2,10 @@ ## Unreleased -### Breaking changes - -* Add `onFirstFlags` to `DatadogFlagsClient`. External implementations and test fakes must implement the new method, which returns an unregister function. Coordinate the core and Flutter integration release before publishing; this source change does not assign a release version. - ### Features +* Add `DatadogFlagsEvents` and the `DatadogFlagsClientEvents.events` extension for retained first-install callbacks with an unregister function. Existing client implementations remain source-compatible; unsupported custom clients are rejected when creating the facade. Coordinate the core and Flutter integration release before publishing; this source change does not assign a release version. + * Retain the first accepted flag-installation event for early and late registrations. Every active registration is delivered once in a microtask; its unregister function suppresses delivery not yet started. ## 1.1.0 diff --git a/packages/datadog_flags/README.md b/packages/datadog_flags/README.md index 4ebdcd0ac..cb4938e7f 100644 --- a/packages/datadog_flags/README.md +++ b/packages/datadog_flags/README.md @@ -372,7 +372,7 @@ Register after obtaining the client, before or after initializing its context: ```dart final client = DatadogFlags.instance.sharedClient(); -final unregister = client.onFirstFlags((event) { +final unregister = client.events.onFirstFlags((event) { print('First installed flags: ${event.flagsChanged}'); final details = client.getBooleanDetails( key: 'checkout.enabled', @@ -405,9 +405,17 @@ work and errors started by a void callback. Pending callbacks remain until an accepted install or unregistration. Reacquire a shared client after SDK re-enable; registrations do not migrate between core clients. -See the actual [typed CLI example](example/bin/typed_evaluation.dart). This API -adds a method to the public client interface, so custom implementations/fakes -must be updated. The release version is coordinated separately. +See the actual [typed CLI example](example/bin/typed_evaluation.dart). The +`DatadogFlagsClientEvents` extension creates a `DatadogFlagsEvents` facade; +`DatadogFlagsEvents(client)` is equivalent. The facade retains its client and +owns no event state. Discarding it does not cancel registrations. + +No member is added to `DatadogFlagsClient`, so existing implementations remain +source-compatible. Facade construction throws `ArgumentError` for unsupported +custom clients and opaque wrappers; retain the original SDK client when wrapping +evaluations. The SDK no-op client accepts registrations without emitting events. +Extensions require a statically typed client and the exported extension in scope; +they are not dynamically dispatched. The release version is coordinated separately. VM-only capture-release checks use actual garbage collection through the local VM service: `dart test test_vm/first_flags_capture_test.dart`, or diff --git a/packages/datadog_flags/example/bin/typed_evaluation.dart b/packages/datadog_flags/example/bin/typed_evaluation.dart index 004f9683b..b80304a38 100644 --- a/packages/datadog_flags/example/bin/typed_evaluation.dart +++ b/packages/datadog_flags/example/bin/typed_evaluation.dart @@ -45,7 +45,7 @@ Future main(List arguments) async { ); final flags = datadogFlags.sharedClient(); - final unregister = flags.onFirstFlags((event) { + final unregister = flags.events.onFirstFlags((event) { stdout.writeln('First installed flags: ${event.flagsChanged}'); _printDetails(_evaluate(flags, flagKey, flagType)); }); diff --git a/packages/datadog_flags/lib/datadog_flags.dart b/packages/datadog_flags/lib/datadog_flags.dart index ab0c4f27e..68b68c238 100644 --- a/packages/datadog_flags/lib/datadog_flags.dart +++ b/packages/datadog_flags/lib/datadog_flags.dart @@ -22,3 +22,6 @@ export 'src/evaluation_context.dart' show FlagsEvaluationContext; export 'src/flags_client_event.dart' show FlagsClientEvent, FlagsClientEventType; + +export 'src/flags_events.dart' + show DatadogFlagsEvents, DatadogFlagsClientEvents; diff --git a/packages/datadog_flags/lib/datadog_flags_internal.dart b/packages/datadog_flags/lib/datadog_flags_internal.dart new file mode 100644 index 000000000..e282a521a --- /dev/null +++ b/packages/datadog_flags/lib/datadog_flags_internal.dart @@ -0,0 +1,8 @@ +// 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. + +// For Datadog companion packages only, not for application use. +// This bridge can change without notice; it is not a supported custom-client API. +export 'src/flags_event_registry.dart' show registerFlagsEventSource; diff --git a/packages/datadog_flags/lib/src/default_flags_client.dart b/packages/datadog_flags/lib/src/default_flags_client.dart index 549c7e277..37ff5e4f9 100644 --- a/packages/datadog_flags/lib/src/default_flags_client.dart +++ b/packages/datadog_flags/lib/src/default_flags_client.dart @@ -9,7 +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_event_registry.dart'; import 'flags_repository.dart'; class DefaultDatadogFlagsClient implements DatadogFlagsClient { @@ -28,11 +28,9 @@ class DefaultDatadogFlagsClient implements DatadogFlagsClient { required EvaluationAggregator evaluationAggregator, }) : _repository = repository, _exposureLogger = exposureLogger, - _evaluationAggregator = evaluationAggregator; - - @override - void Function() onFirstFlags(void Function(FlagsClientEvent) callback) => - _repository.onFirstFlags(callback); + _evaluationAggregator = evaluationAggregator { + registerFlagsEventSource(this, _repository.onFirstFlags); + } @override Future initialize(FlagsEvaluationContext context) async { diff --git a/packages/datadog_flags/lib/src/flags_client.dart b/packages/datadog_flags/lib/src/flags_client.dart index a58d4e21f..fb18e8996 100644 --- a/packages/datadog_flags/lib/src/flags_client.dart +++ b/packages/datadog_flags/lib/src/flags_client.dart @@ -5,7 +5,6 @@ import 'evaluation_context.dart'; import 'flags_error.dart'; -import 'flags_client_event.dart'; /// Evaluates feature flags for one current evaluation context. /// @@ -16,25 +15,6 @@ abstract interface class DatadogFlagsClient { /// Stable name assigned by [DatadogFlags.sharedClient]. String get name; - /// Registers [callback] for this client's first accepted cache or network - /// installation, including an empty configuration. Every registration receives - /// the retained first event once, even if registered after later updates. - /// Missing, invalid or rejected data does not complete this notification. - /// - /// Delivery always runs in a microtask, including late registrations. It does - /// not wait for initialization or persistence to complete. Evaluations in the - /// callback read current assignments, not a snapshot pinned to the event. - /// Synchronous callback exceptions are isolated; asynchronous work and errors - /// started by the callback belong to the application. - /// - /// Returns an idempotent unregister function. It releases the callback and - /// suppresses delivery that has not started, including an already queued - /// microtask. It cannot interrupt a running callback, clear the retained event - /// or cancel initialization. Reset does not rearm or erase the first event. - /// Pending callbacks remain until installation or explicit unregistration. - /// Reacquire a shared client after SDK re-enable; registrations do not migrate. - void Function() onFirstFlags(void Function(FlagsClientEvent) callback); - /// Fetches assignments for [context] and makes them available to evaluations. /// /// The first call completes when initialization finishes or the configured diff --git a/packages/datadog_flags/lib/src/flags_event_registry.dart b/packages/datadog_flags/lib/src/flags_event_registry.dart new file mode 100644 index 000000000..110cc728b --- /dev/null +++ b/packages/datadog_flags/lib/src/flags_event_registry.dart @@ -0,0 +1,34 @@ +// 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 'flags_client.dart'; +import 'flags_client_event.dart'; + +// Weak identity association: the registry does not keep clients (or callbacks +// bound to those clients) alive. No application listener is stored here. +final _sources = + Expando( + 'flags events'); + +/// SDK companion-package bridge, not an application extension point. +void registerFlagsEventSource( + DatadogFlagsClient client, + void Function() Function(void Function(FlagsClientEvent)) register, +) { + if (_sources[client] != null) { + throw StateError('Flags event source is already registered'); + } + _sources[client] = register; +} + +void Function() Function(void Function(FlagsClientEvent)) flagsEventSource( + DatadogFlagsClient client, +) { + final source = _sources[client]; + if (source == null) { + throw ArgumentError('Events require an SDK-created Datadog flags client'); + } + return source; +} diff --git a/packages/datadog_flags/lib/src/flags_events.dart b/packages/datadog_flags/lib/src/flags_events.dart new file mode 100644 index 000000000..edb3b9741 --- /dev/null +++ b/packages/datadog_flags/lib/src/flags_events.dart @@ -0,0 +1,52 @@ +// 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 'flags_client.dart'; +import 'flags_client_event.dart'; +import 'flags_event_registry.dart'; + +/// Event access for a native Datadog core or Flutter flags client. +/// +/// This lightweight facade retains its client, but owns no event or registration +/// state. Discarding it does not unregister callbacks; use the returned function. +final class DatadogFlagsEvents { + final DatadogFlagsClient _client; + + /// Creates event access for an SDK client, including the SDK's no-op client. + /// + /// Throws [ArgumentError] immediately for custom implementations and opaque + /// wrappers. Keep the original SDK client when wrapping evaluations yourself. + /// The SDK no-op client accepts registration without delivering an event. + DatadogFlagsEvents(DatadogFlagsClient client) : _client = client { + flagsEventSource(client); + } + + /// Registers [listener] for this client's first accepted cache or network + /// installation, including an empty configuration. Every registration receives + /// the retained first event once, even if registered after later updates. + /// Missing, invalid or rejected data does not complete this notification. + /// + /// Delivery always runs in a microtask, including late registrations. It does + /// not wait for initialization or persistence to complete. Evaluations in the + /// callback read current assignments, not a snapshot pinned to the event. + /// Synchronous callback exceptions are isolated; asynchronous work and errors + /// started by the callback belong to the application. + /// + /// Returns an idempotent unregister function. It releases the callback and + /// suppresses delivery that has not started, including an already queued + /// microtask. It cannot interrupt a running callback, clear the retained event + /// or cancel initialization. Reset does not rearm or erase the first event. + /// Pending callbacks remain until installation or explicit unregistration. + /// Reacquire a shared client after SDK re-enable; registrations do not migrate. + void Function() onFirstFlags( + void Function(FlagsClientEvent event) listener) => + flagsEventSource(_client)(listener); +} + +/// Event access without adding members to the evaluation client interface. +extension DatadogFlagsClientEvents on DatadogFlagsClient { + /// Creates a lightweight events facade; throws for unsupported custom clients. + DatadogFlagsEvents get events => DatadogFlagsEvents(this); +} 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 655f77a13..85afaff7d 100644 --- a/packages/datadog_flags/lib/src/no_op_flags_client.dart +++ b/packages/datadog_flags/lib/src/no_op_flags_client.dart @@ -7,16 +7,15 @@ import 'evaluation_context.dart'; import 'flags_client.dart'; import 'flags_error.dart'; import 'flags_client_event.dart'; +import 'flags_event_registry.dart'; class NoOpDatadogFlagsClient implements DatadogFlagsClient { @override final String name; - const NoOpDatadogFlagsClient({required this.name}); - - @override - void Function() onFirstFlags(void Function(FlagsClientEvent) callback) => - () {}; + NoOpDatadogFlagsClient({required this.name}) { + registerFlagsEventSource(this, _noOpRegistration); + } @override Future initialize(FlagsEvaluationContext context) async {} @@ -78,3 +77,6 @@ class NoOpDatadogFlagsClient implements DatadogFlagsClient { ); } } + +void Function() _noOpRegistration(void Function(FlagsClientEvent) listener) => + () {}; diff --git a/packages/datadog_flags/test/first_flags_callback_test.dart b/packages/datadog_flags/test/first_flags_callback_test.dart index c9b038f13..66bf100e7 100644 --- a/packages/datadog_flags/test/first_flags_callback_test.dart +++ b/packages/datadog_flags/test/first_flags_callback_test.dart @@ -71,7 +71,7 @@ Future _owner({ initializationTimeout: timeout, httpClient: MockClient(request ?? (_) async => _response()), )); - flags.sharedClient().onFirstFlags(callback); + flags.sharedClient().events.onFirstFlags(callback); addTearDown(flags.disable); return flags; } @@ -216,7 +216,7 @@ void main() { final flags = await _owner(callback: events.add); await flags.sharedClient().initialize(_context); await flags.sharedClient().initialize(_context); - flags.sharedClient(name: 'other').onFirstFlags(events.add); + flags.sharedClient(name: 'other').events.onFirstFlags(events.add); await flags.sharedClient(name: 'other').initialize(_context); expect(events, hasLength(2)); }); diff --git a/packages/datadog_flags/test/first_flags_registration_test.dart b/packages/datadog_flags/test/first_flags_registration_test.dart index 5b3cacc74..2ed867f94 100644 --- a/packages/datadog_flags/test/first_flags_registration_test.dart +++ b/packages/datadog_flags/test/first_flags_registration_test.dart @@ -47,14 +47,14 @@ void main() { () async { final client = (await _owner()).sharedClient(); final events = []; - client.onFirstFlags(events.add); - client.onFirstFlags(events.add); + client.events.onFirstFlags(events.add); + client.events.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); + client.events.onFirstFlags(events.add); expect(events, hasLength(2)); await _flush(); expect(events, hasLength(3)); @@ -64,13 +64,13 @@ void main() { final client = (await _owner()).sharedClient(); var calls = 0; void callback(FlagsClientEvent _) => calls++; - final cancel = client.onFirstFlags(callback); - client.onFirstFlags(callback); + final cancel = client.events.onFirstFlags(callback); + client.events.onFirstFlags(callback); cancel(); cancel(); await client.initialize(_context); expect(calls, 1); - client.onFirstFlags(callback); + client.events.onFirstFlags(callback); await _flush(); expect(calls, 2); }); @@ -78,12 +78,12 @@ void main() { final client = (await _owner()).sharedClient(); await client.initialize(_context); var calls = 0; - final cancel = client.onFirstFlags((_) => calls++); + final cancel = client.events.onFirstFlags((_) => calls++); expect(calls, 0); cancel(); await _flush(); expect(calls, 0); - final after = client.onFirstFlags((_) => calls++); + final after = client.events.onFirstFlags((_) => calls++); await _flush(); after(); after(); @@ -96,12 +96,12 @@ void main() { var calls = 0; late void Function() second; late void Function() first; - first = client.onFirstFlags((_) { + first = client.events.onFirstFlags((_) { first(); second(); calls++; }); - second = client.onFirstFlags((_) => calls += 100); + second = client.events.onFirstFlags((_) => calls += 100); await client.initialize(_context); expect(calls, 1); }); @@ -109,13 +109,13 @@ void main() { () async { final client = (await _owner()).sharedClient(); final order = []; - client.onFirstFlags((_) { + client.events.onFirstFlags((_) { order.add('first'); - client.onFirstFlags((_) => order.add('nested')); + client.events.onFirstFlags((_) => order.add('nested')); expect(order, ['first']); throw StateError('app'); }); - client.onFirstFlags((_) => order.add('second')); + client.events.onFirstFlags((_) => order.add('second')); await client.initialize(_context); await _flush(); expect(order, ['first', 'second', 'nested']); @@ -124,11 +124,11 @@ void main() { () async { final client = (await _owner()).sharedClient(); var canceledCalls = 0; - final cancel = client.onFirstFlags((_) => canceledCalls++); + final cancel = client.events.onFirstFlags((_) => canceledCalls++); cancel(); await client.initialize(_context); final delivered = Completer(); - client.onFirstFlags(delivered.complete); + client.events.onFirstFlags(delivered.complete); expect((await delivered.future).flagsChanged, ['checkout.enabled']); expect(canceledCalls, 0); expect( @@ -145,7 +145,7 @@ void main() { await client.reset(); await client.shutdown(); final delivered = Completer(); - client.onFirstFlags(delivered.complete); + client.events.onFirstFlags(delivered.complete); expect(delivered.isCompleted, isFalse); expect((await delivered.future).flagsChanged, ['checkout.enabled']); expect( @@ -158,7 +158,7 @@ void main() { final owner = DatadogFlags(); final client = owner.sharedClient(); var calls = 0; - final cancel = client.onFirstFlags((_) => calls++); + final cancel = client.events.onFirstFlags((_) => calls++); await client.initialize(_context); cancel(); cancel(); diff --git a/packages/datadog_flags/test/flags_events_compatibility_test.dart b/packages/datadog_flags/test/flags_events_compatibility_test.dart new file mode 100644 index 000000000..1c5f15f18 --- /dev/null +++ b/packages/datadog_flags/test/flags_events_compatibility_test.dart @@ -0,0 +1,86 @@ +// 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'; + +// Deliberately implements every original member without Fake/noSuchMethod. +class LegacyClient implements DatadogFlagsClient { + @override + String get name => 'legacy'; + @override + Future initialize(FlagsEvaluationContext context) async {} + @override + Future reset() async {} + @override + Future shutdown() async {} + @override + FlagDetails getBooleanDetails( + {required String key, required bool defaultValue}) => + FlagDetails(key: key, value: defaultValue); + @override + FlagDetails getStringDetails( + {required String key, required String defaultValue}) => + FlagDetails(key: key, value: defaultValue); + @override + FlagDetails getIntegerDetails( + {required String key, required int defaultValue}) => + FlagDetails(key: key, value: defaultValue); + @override + FlagDetails getDoubleDetails( + {required String key, required double defaultValue}) => + FlagDetails(key: key, value: defaultValue); + @override + FlagDetails getObjectDetails( + {required String key, required Object? defaultValue}) => + FlagDetails(key: key, value: defaultValue); +} + +class OpaqueClient extends LegacyClient { + final DatadogFlagsClient delegate; + OpaqueClient(this.delegate); + @override + String get name => delegate.name; +} + +class ClientWithEvents extends LegacyClient { + String get events => 'custom'; +} + +void main() { + test('opaque wrapper is rejected even when it holds a native client', () { + final client = OpaqueClient(DatadogFlags().sharedClient()); + expect(() => DatadogFlagsEvents(client), throwsArgumentError); + }); + test('SDK no-op accepts both access forms without fabricating an event', + () async { + final client = DatadogFlags().sharedClient(); + final first = client.events; + final second = DatadogFlagsEvents(client); + expect(identical(first, second), isFalse); + var calls = 0; + final cancel = first.onFirstFlags((_) => calls++); + second.onFirstFlags((_) => calls++); + cancel(); + cancel(); + await Future.delayed(Duration.zero); + expect(calls, 0); + }); + test( + 'custom instance member wins; explicit extension still validates support', + () { + final client = ClientWithEvents(); + expect(client.events, 'custom'); + expect(() => DatadogFlagsClientEvents(client).events, throwsArgumentError); + }); + test('legacy implementor compiles but has no implicit event capability', () { + final client = LegacyClient(); + expect(client.getBooleanDetails(key: 'flag', defaultValue: true).value, + isTrue); + expect(() => DatadogFlagsEvents(client), throwsArgumentError); + expect(() => client.events, throwsArgumentError); + expect(() => DatadogFlagsClientEvents(client).events, throwsArgumentError); + }); +} diff --git a/packages/datadog_flags/test_vm/first_flags_capture_test.dart b/packages/datadog_flags/test_vm/first_flags_capture_test.dart index 5bdadcb26..2a0688653 100644 --- a/packages/datadog_flags/test_vm/first_flags_capture_test.dart +++ b/packages/datadog_flags/test_vm/first_flags_capture_test.dart @@ -17,11 +17,41 @@ import 'helpers/force_gc.dart'; (WeakReference, void Function()) register(DatadogFlagsClient client) { final capture = Capture(); final reference = WeakReference(capture); - final unregister = client.onFirstFlags((_) => capture.use()); + final unregister = client.events.onFirstFlags((_) => capture.use()); return (reference, unregister); } +@pragma('vm:never-inline') +WeakReference registerThroughFacade( + DatadogFlagsClient client, void Function(FlagsClientEvent) callback) { + final events = client.events; + events.onFirstFlags(callback); + return WeakReference(events); +} + void main() { + test('collecting facade does not cancel client-owned pending registration', + () 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 client = owner.sharedClient(); + final delivered = Completer(); + final facade = registerThroughFacade(client, delivered.complete); + await collectGarbage(); + expect(hasTarget(facade), isFalse); + await client.initialize(FlagsEvaluationContext.empty); + expect((await delivered.future).flagsChanged, isEmpty); + final replay = Completer(); + DatadogFlagsEvents(client).onFirstFlags(replay.complete); + expect(await replay.future, same(await delivered.future)); + }); for (final queued in [false, true]) { test( 'cancel releases ${queued ? 'queued' : 'pending'} captures while token remains live', diff --git a/packages/datadog_flags/test_vm/helpers/force_gc.dart b/packages/datadog_flags/test_vm/helpers/force_gc.dart index 4edc8d3dd..dff0dce42 100644 --- a/packages/datadog_flags/test_vm/helpers/force_gc.dart +++ b/packages/datadog_flags/test_vm/helpers/force_gc.dart @@ -55,4 +55,5 @@ class Capture { } @pragma('vm:never-inline') -bool hasTarget(WeakReference reference) => reference.target != null; +bool hasTarget(WeakReference reference) => + reference.target != null; diff --git a/packages/datadog_flags_flutter/CHANGELOG.md b/packages/datadog_flags_flutter/CHANGELOG.md index 1157f7195..229237902 100644 --- a/packages/datadog_flags_flutter/CHANGELOG.md +++ b/packages/datadog_flags_flutter/CHANGELOG.md @@ -2,12 +2,10 @@ ## Unreleased -### Breaking changes - -* Add `onFirstFlags` to `DatadogFlagsClient`. External implementations and test fakes must implement the new method, which returns an unregister function. Coordinate the core and Flutter integration release before publishing; this source change does not assign a release version. - ### Features +* Add `DatadogFlagsEvents` and the `DatadogFlagsClientEvents.events` extension for retained first-install callbacks with an unregister function. Existing client implementations remain source-compatible; unsupported custom clients are rejected when creating the facade. Coordinate the core and Flutter integration release before publishing; this source change does not assign a release version. + * Retain the first accepted flag-installation event for early and late registrations. Every active registration is delivered once in a microtask; its unregister function suppresses delivery not yet started. ## 1.1.0 diff --git a/packages/datadog_flags_flutter/README.md b/packages/datadog_flags_flutter/README.md index 478fd302d..b87d381be 100644 --- a/packages/datadog_flags_flutter/README.md +++ b/packages/datadog_flags_flutter/README.md @@ -130,7 +130,7 @@ Pull requests are welcome. For more information, read the ## First installed flags -Call `client.onFirstFlags((event) { ... })` after obtaining a shared client. It +Call `client.events.onFirstFlags((event) { ... })` after obtaining a shared client. It returns an ordinary unregister function; there is no subscription object. The callback receives only `FlagsClientEvent` and always runs in a microtask, including when the first event is already retained. The wrapper resolves its @@ -157,8 +157,16 @@ Run the VM-only capture-release proof with ### Coordinated release requirement -Adding the method breaks external `implements DatadogFlagsClient` classes and -fakes. The repository releaser assigns an explicit release version; this change +The facade adds no members to `DatadogFlagsClient` or +`DatadogFlutterFlagsClient`; existing implementations and fakes remain +source-compatible. Both the concrete facade and its named extension are exported +from this package. Facade construction throws `ArgumentError` for unsupported +custom clients or opaque wrappers. Keep the original SDK client when wrapping +evaluations; the SDK no-op client remains supported without notification. +A facade retains its client, but discarding it never cancels a registration. +The wrapper's internal asynchronous delegate resolution remains best effort. + +The repository releaser assigns an explicit release version; this change does not select one. Before publishing the integration, publish the coordinated core containing this API and ensure the minimum dependency selects that release. The wrapper requires `datadog_flags: ^1.2.0` for CACHED support; the published diff --git a/packages/datadog_flags_flutter/example/lib/main.dart b/packages/datadog_flags_flutter/example/lib/main.dart index 244659bd3..8e8747614 100644 --- a/packages/datadog_flags_flutter/example/lib/main.dart +++ b/packages/datadog_flags_flutter/example/lib/main.dart @@ -80,7 +80,7 @@ class _FlagsExampleAppState extends State { super.initState(); final client = DatadogSdk.instance.flags?.sharedClient(); if (widget.isConfigured && client != null) { - _unregisterFirstFlags = client.onFirstFlags((event) { + _unregisterFirstFlags = client.events.onFirstFlags((event) { debugPrint('First installed flags: ${event.flagsChanged}'); final details = client.getBooleanDetails( key: _flagKey, diff --git a/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart b/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart index ba95d5329..a28270cdc 100644 --- a/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart +++ b/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart @@ -7,6 +7,8 @@ export 'package:datadog_flags/datadog_flags.dart' show DatadogFlagsConfig, DatadogFlagsClient, + DatadogFlagsEvents, + DatadogFlagsClientEvents, DatadogFlagsConfiguration, FlagsClientEvent, FlagsClientEventType, 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 0cb5db234..fd3de6772 100644 --- a/packages/datadog_flags_flutter/lib/src/datadog_flags_plugin.dart +++ b/packages/datadog_flags_flutter/lib/src/datadog_flags_plugin.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:datadog_flags/datadog_flags.dart'; +import 'package:datadog_flags/datadog_flags_internal.dart'; import 'package:datadog_flutter_plugin/datadog_flutter_plugin.dart'; import 'package:flutter/foundation.dart'; @@ -182,14 +183,15 @@ class DatadogFlutterFlagsClient implements DatadogFlagsClient { required void Function(String key, Object value)? addRumFeatureFlagEvaluation, }) : _resolveDelegate = resolveDelegate, - _addRumFeatureFlagEvaluation = addRumFeatureFlagEvaluation; + _addRumFeatureFlagEvaluation = addRumFeatureFlagEvaluation { + registerFlagsEventSource(this, _onFirstFlags); + } /// Always delivers in a microtask after binding the existing core delegate. /// Cancellation works immediately, including while that delegate is resolving. /// Each registration forwards once to its resolved core; there is no retry or /// migration across re-enable. Reacquire a shared client after re-enable. - @override - void Function() onFirstFlags(void Function(FlagsClientEvent) callback) { + void Function() _onFirstFlags(void Function(FlagsClientEvent) callback) { final registration = _FlutterFirstFlagsRegistration(callback); unawaited(_forwardFirstFlags(registration)); return registration.cancel; @@ -201,7 +203,7 @@ class DatadogFlutterFlagsClient implements DatadogFlagsClient { try { final delegate = await _delegateOrResolve(); if (!registration.isActive) return; - final unregister = delegate.onFirstFlags(registration.schedule); + final unregister = delegate.events.onFirstFlags(registration.schedule); registration.attach(unregister); } catch (_) { // Registration is best effort; initialization retains its own error path. diff --git a/packages/datadog_flags_flutter/test/first_flags_registration_test.dart b/packages/datadog_flags_flutter/test/first_flags_registration_test.dart index 63070a03f..daa6971bc 100644 --- a/packages/datadog_flags_flutter/test/first_flags_registration_test.dart +++ b/packages/datadog_flags_flutter/test/first_flags_registration_test.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:datadog_flags/datadog_flags.dart'; +import 'package:datadog_flags/datadog_flags_internal.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'; @@ -23,14 +24,16 @@ class _Sdk extends Mock implements DatadogSdk {} class _Rum extends Mock implements DatadogRum {} class _Delegate extends Fake implements DatadogFlagsClient { + _Delegate() { + registerFlagsEventSource(this, _register); + } int registrations = 0; int initializations = 0; int cancellations = 0; void Function(FlagsClientEvent)? listener; void Function()? duringRegistration; bool throwCancel = false; - @override - void Function() onFirstFlags(void Function(FlagsClientEvent) callback) { + void Function() _register(void Function(FlagsClientEvent) callback) { registrations++; listener = callback; duringRegistration?.call(); @@ -59,7 +62,7 @@ void main() { final core = _Delegate(); final client = createFirstFlagsTestClient(() => resolving.future); var calls = 0; - final cancel = client.onFirstFlags((_) => calls++); + final cancel = client.events.onFirstFlags((_) => calls++); cancel(); cancel(); resolving.complete(core); @@ -72,7 +75,7 @@ void main() { final resolving = Completer(); final client = createFirstFlagsTestClient(() => resolving.future); var calls = 0; - final cancel = client.onFirstFlags((_) => calls++); + final cancel = client.events.onFirstFlags((_) => calls++); resolving.completeError(StateError('enable failed')); await _flush(); cancel(); @@ -86,7 +89,7 @@ void main() { final core = _Delegate(); final client = createFirstFlagsTestClient(() async => core); var calls = 0; - final cancel = client.onFirstFlags((_) => calls++); + final cancel = client.events.onFirstFlags((_) => calls++); await _flush(); core.listener!(_event); expect(calls, 0); @@ -109,7 +112,7 @@ void main() { core.listener!(_event); duringCalls = calls; }; - final cancel = client.onFirstFlags((_) => calls++); + final cancel = client.events.onFirstFlags((_) => calls++); expect(calls, 0); await _flush(); expect(calls, 1); @@ -128,7 +131,7 @@ void main() { core.listener!(_event); cancel(); }; - cancel = client.onFirstFlags((_) => calls++); + cancel = client.events.onFirstFlags((_) => calls++); await _flush(); cancel(); expect(core.cancellations, 1); @@ -137,7 +140,7 @@ void main() { test('callback exception does not produce failed SDK Future', () async { final core = _Delegate(); final client = createFirstFlagsTestClient(() async => core); - client.onFirstFlags((_) => throw StateError('app')); + client.events.onFirstFlags((_) => throw StateError('app')); await _flush(); core.listener!(_event); await _flush(); @@ -153,7 +156,7 @@ void main() { return resolving.future; }); var calls = 0; - final cancel = client.onFirstFlags((_) => calls++); + final cancel = client.events.onFirstFlags((_) => calls++); await client.shutdown(); resolving.complete(coreA); await _flush(); @@ -165,7 +168,7 @@ void main() { expect(calls, 1); cancel(); final replacement = createFirstFlagsTestClient(() async => coreB); - final cancelB = replacement.onFirstFlags((_) {}); + final cancelB = replacement.events.onFirstFlags((_) {}); await _flush(); expect(coreB.registrations, 1); cancelB(); @@ -211,7 +214,7 @@ void main() { final client = plugin.sharedClient(); final events = []; FlagDetails? details; - client.onFirstFlags((event) { + client.events.onFirstFlags((event) { events.add(event); details = client.getBooleanDetails( key: 'checkout.enabled', defaultValue: false); @@ -224,7 +227,7 @@ void main() { expect(requests, 1); verify(() => rum.addFeatureFlagEvaluation('checkout.enabled', 'enabled')) .called(1); - final cancel = client.onFirstFlags(events.add); + final cancel = client.events.onFirstFlags(events.add); cancel(); await _flush(); expect(events, hasLength(1)); @@ -235,7 +238,7 @@ void main() { final core = _Delegate(); final client = createFirstFlagsTestClient(() => resolving.future); var calls = 0; - client.onFirstFlags((_) => calls++); + client.events.onFirstFlags((_) => calls++); expect(core.registrations, 0); resolving.complete(core); await _flush(); diff --git a/packages/datadog_flags_flutter/test/flags_events_compatibility_test.dart b/packages/datadog_flags_flutter/test/flags_events_compatibility_test.dart new file mode 100644 index 000000000..9304c2ab4 --- /dev/null +++ b/packages/datadog_flags_flutter/test/flags_events_compatibility_test.dart @@ -0,0 +1,50 @@ +// 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'; +import 'package:flutter_test/flutter_test.dart'; + +// Deliberately implements every original member without Fake/noSuchMethod. +class LegacyClient implements DatadogFlagsClient, DatadogFlutterFlagsClient { + @override + String get name => 'legacy'; + @override + Future initialize(FlagsEvaluationContext context) async {} + @override + Future reset() async {} + @override + Future shutdown() async {} + @override + FlagDetails getBooleanDetails( + {required String key, required bool defaultValue}) => + FlagDetails(key: key, value: defaultValue); + @override + FlagDetails getStringDetails( + {required String key, required String defaultValue}) => + FlagDetails(key: key, value: defaultValue); + @override + FlagDetails getIntegerDetails( + {required String key, required int defaultValue}) => + FlagDetails(key: key, value: defaultValue); + @override + FlagDetails getDoubleDetails( + {required String key, required double defaultValue}) => + FlagDetails(key: key, value: defaultValue); + @override + FlagDetails getObjectDetails( + {required String key, required Object? defaultValue}) => + FlagDetails(key: key, value: defaultValue); +} + +void main() { + test('legacy implementor compiles but has no implicit event capability', () { + final client = LegacyClient(); + expect(client.getBooleanDetails(key: 'flag', defaultValue: true).value, + isTrue); + expect(() => DatadogFlagsEvents(client), throwsArgumentError); + expect(() => client.events, throwsArgumentError); + expect(() => DatadogFlagsClientEvents(client).events, throwsArgumentError); + }); +} diff --git a/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart b/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart index 33a02a971..9c8035ace 100644 --- a/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart +++ b/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:datadog_flags/datadog_flags.dart'; +import 'package:datadog_flags/datadog_flags_internal.dart'; import 'package:datadog_flags_flutter/datadog_flags_flutter.dart'; import 'package:flutter_test/flutter_test.dart'; import 'helpers/force_gc.dart'; @@ -15,19 +16,51 @@ import '../test/helpers/first_flags_test_client.dart'; DatadogFlutterFlagsClient client) { final capture = Capture(); final reference = WeakReference(capture); - return (reference, client.onFirstFlags((_) => capture.use())); + return (reference, client.events.onFirstFlags((_) => capture.use())); } class _Delegate extends Fake implements DatadogFlagsClient { + _Delegate() { + registerFlagsEventSource(this, _register); + } void Function(FlagsClientEvent)? listener; - @override - void Function() onFirstFlags(void Function(FlagsClientEvent) callback) { + void Function() _register(void Function(FlagsClientEvent) callback) { listener = callback; return () => listener = null; } } +@pragma('vm:never-inline') +WeakReference unownedClient() { + final client = + createFirstFlagsTestClient(() async => DatadogFlags().sharedClient()); + return WeakReference(client); +} + +@pragma('vm:never-inline') +(WeakReference, DatadogFlagsEvents) + retainedFacade() { + final client = + createFirstFlagsTestClient(() async => DatadogFlags().sharedClient()); + return (WeakReference(client), client.events); +} + void main() { + test( + 'weak bridge does not retain wrapper through its bound registration method', + () async { + final client = unownedClient(); + await collectGarbage(); + expect(hasTarget(client), isFalse); + }); + test('live facade retains its client and remains usable after collection', + () async { + final pair = retainedFacade(); + await collectGarbage(); + expect(hasTarget(pair.$1), isTrue); + final cancel = pair.$2.onFirstFlags((_) => fail('no-op must not deliver')); + cancel(); + }); test('cancel releases forwarded capture while app microtask remains queued', () async { final core = _Delegate(); diff --git a/packages/datadog_flags_flutter/test_vm/helpers/force_gc.dart b/packages/datadog_flags_flutter/test_vm/helpers/force_gc.dart index 4edc8d3dd..dff0dce42 100644 --- a/packages/datadog_flags_flutter/test_vm/helpers/force_gc.dart +++ b/packages/datadog_flags_flutter/test_vm/helpers/force_gc.dart @@ -55,4 +55,5 @@ class Capture { } @pragma('vm:never-inline') -bool hasTarget(WeakReference reference) => reference.target != null; +bool hasTarget(WeakReference reference) => + reference.target != null; From 0cecd05e273453573d5003556f715c3491ae4a1a Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 5 Oct 2026 14:16:03 -0600 Subject: [PATCH 3/5] fix(flags): preserve first flags callback registration zones --- .../datadog_flags/lib/src/flags_events.dart | 3 ++- .../lib/src/flags_repository.dart | 3 ++- .../test/first_flags_registration_test.dart | 27 +++++++++++++++++++ .../test_vm/first_flags_capture_test.dart | 4 ++- .../lib/src/datadog_flags_plugin.dart | 3 ++- .../test/first_flags_registration_test.dart | 24 +++++++++++++++++ .../test_vm/first_flags_capture_test.dart | 6 ++++- 7 files changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/datadog_flags/lib/src/flags_events.dart b/packages/datadog_flags/lib/src/flags_events.dart index edb3b9741..03b9bac62 100644 --- a/packages/datadog_flags/lib/src/flags_events.dart +++ b/packages/datadog_flags/lib/src/flags_events.dart @@ -28,7 +28,8 @@ final class DatadogFlagsEvents { /// the retained first event once, even if registered after later updates. /// Missing, invalid or rejected data does not complete this notification. /// - /// Delivery always runs in a microtask, including late registrations. It does + /// Delivery always runs in a microtask in the registration zone, including + /// late registrations. It does /// not wait for initialization or persistence to complete. Evaluations in the /// callback read current assignments, not a snapshot pinned to the event. /// Synchronous callback exceptions are isolated; asynchronous work and errors diff --git a/packages/datadog_flags/lib/src/flags_repository.dart b/packages/datadog_flags/lib/src/flags_repository.dart index d24622054..5d7833d45 100644 --- a/packages/datadog_flags/lib/src/flags_repository.dart +++ b/packages/datadog_flags/lib/src/flags_repository.dart @@ -323,7 +323,8 @@ class _FirstFlagsRegistration { void Function(FlagsClientEvent)? _callback; void Function()? removePending; - _FirstFlagsRegistration(this._callback); + _FirstFlagsRegistration(void Function(FlagsClientEvent) callback) + : _callback = Zone.current.bindUnaryCallback(callback); void cancel() { _callback = null; diff --git a/packages/datadog_flags/test/first_flags_registration_test.dart b/packages/datadog_flags/test/first_flags_registration_test.dart index 2ed867f94..1468e5dec 100644 --- a/packages/datadog_flags/test/first_flags_registration_test.dart +++ b/packages/datadog_flags/test/first_flags_registration_test.dart @@ -42,6 +42,33 @@ Future _owner() async { Future _flush() => 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.events.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 _flush(); + 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 { diff --git a/packages/datadog_flags/test_vm/first_flags_capture_test.dart b/packages/datadog_flags/test_vm/first_flags_capture_test.dart index 2a0688653..720dc13af 100644 --- a/packages/datadog_flags/test_vm/first_flags_capture_test.dart +++ b/packages/datadog_flags/test_vm/first_flags_capture_test.dart @@ -17,7 +17,9 @@ import 'helpers/force_gc.dart'; (WeakReference, void Function()) register(DatadogFlagsClient client) { final capture = Capture(); final reference = WeakReference(capture); - final unregister = client.events.onFirstFlags((_) => capture.use()); + final unregister = runZoned( + () => client.events.onFirstFlags((_) => capture.use()), + zoneValues: {#registration: 'capture-test'}); return (reference, unregister); } 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 fd3de6772..e24f05b14 100644 --- a/packages/datadog_flags_flutter/lib/src/datadog_flags_plugin.dart +++ b/packages/datadog_flags_flutter/lib/src/datadog_flags_plugin.dart @@ -355,7 +355,8 @@ class _FlutterFirstFlagsRegistration { void Function(FlagsClientEvent)? _callback; void Function()? _unregister; - _FlutterFirstFlagsRegistration(this._callback); + _FlutterFirstFlagsRegistration(void Function(FlagsClientEvent) callback) + : _callback = Zone.current.bindUnaryCallback(callback); bool get isActive => _callback != null; diff --git a/packages/datadog_flags_flutter/test/first_flags_registration_test.dart b/packages/datadog_flags_flutter/test/first_flags_registration_test.dart index daa6971bc..7dc6cbbf6 100644 --- a/packages/datadog_flags_flutter/test/first_flags_registration_test.dart +++ b/packages/datadog_flags_flutter/test/first_flags_registration_test.dart @@ -56,6 +56,30 @@ class _Delegate extends Fake implements DatadogFlagsClient { Future _flush() => Future.delayed(Duration.zero); void main() { + test( + 'wrapper callback uses registration zone when delegate notifies elsewhere', + () async { + final core = _Delegate(); + final client = createFirstFlagsTestClient(() async => core); + Object? observed; + final errors = []; + runZonedGuarded(() { + client.events.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'}); + await _flush(); + runZoned(() => core.listener!(_event), + zoneValues: {#listenerZone: 'notification'}); + await _flush(); + expect(observed, 'registration'); + expect(errors, hasLength(1)); + expect(errors.single.toString(), contains('async listener error')); + }); + test('cancel while resolving releases registration without forwarding', () async { final resolving = Completer(); diff --git a/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart b/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart index 9c8035ace..1b7dbab93 100644 --- a/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart +++ b/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart @@ -16,7 +16,11 @@ import '../test/helpers/first_flags_test_client.dart'; DatadogFlutterFlagsClient client) { final capture = Capture(); final reference = WeakReference(capture); - return (reference, client.events.onFirstFlags((_) => capture.use())); + return ( + reference, + runZoned(() => client.events.onFirstFlags((_) => capture.use()), + zoneValues: {#registration: 'capture-test'}) + ); } class _Delegate extends Fake implements DatadogFlagsClient { From e406e1226934b9014f2c5e9f1d7a91d58d19d73e Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Tue, 6 Oct 2026 09:40:17 -0600 Subject: [PATCH 4/5] Expose retained first flags callbacks directly on clients --- packages/datadog_flags/CHANGELOG.md | 2 +- packages/datadog_flags/README.md | 19 ++-- .../example/bin/typed_evaluation.dart | 2 +- packages/datadog_flags/lib/datadog_flags.dart | 3 - .../lib/datadog_flags_internal.dart | 8 -- .../lib/src/default_flags_client.dart | 11 ++- .../datadog_flags/lib/src/flags_client.dart | 23 +++++ .../lib/src/flags_event_registry.dart | 34 -------- .../datadog_flags/lib/src/flags_events.dart | 53 ------------ .../lib/src/no_op_flags_client.dart | 13 ++- .../test/first_flags_callback_test.dart | 4 +- .../test/first_flags_registration_test.dart | 36 ++++---- .../test/flags_client_interface_test.dart | 67 +++++++++++++++ .../test/flags_events_compatibility_test.dart | 86 ------------------- .../test_vm/first_flags_capture_test.dart | 33 +------ packages/datadog_flags_flutter/CHANGELOG.md | 2 +- packages/datadog_flags_flutter/README.md | 16 ++-- .../example/lib/main.dart | 2 +- .../lib/datadog_flags_flutter.dart | 2 - .../lib/src/datadog_flags_plugin.dart | 10 +-- .../test/first_flags_registration_test.dart | 31 +++---- ....dart => flags_client_interface_test.dart} | 33 +++++-- .../test_vm/first_flags_capture_test.dart | 39 +-------- 23 files changed, 188 insertions(+), 341 deletions(-) delete mode 100644 packages/datadog_flags/lib/datadog_flags_internal.dart delete mode 100644 packages/datadog_flags/lib/src/flags_event_registry.dart delete mode 100644 packages/datadog_flags/lib/src/flags_events.dart create mode 100644 packages/datadog_flags/test/flags_client_interface_test.dart delete mode 100644 packages/datadog_flags/test/flags_events_compatibility_test.dart rename packages/datadog_flags_flutter/test/{flags_events_compatibility_test.dart => flags_client_interface_test.dart} (63%) diff --git a/packages/datadog_flags/CHANGELOG.md b/packages/datadog_flags/CHANGELOG.md index 0fafce17f..72f229bfe 100644 --- a/packages/datadog_flags/CHANGELOG.md +++ b/packages/datadog_flags/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -* Add `DatadogFlagsEvents` and the `DatadogFlagsClientEvents.events` extension for retained first-install callbacks with an unregister function. Existing client implementations remain source-compatible; unsupported custom clients are rejected when creating the facade. Coordinate the core and Flutter integration release before publishing; this source change does not assign a release version. +* Add `onFirstFlags` directly to `DatadogFlagsClient` for retained first-install callbacks with a registration-local unregister function. Custom implementations and test doubles must implement the new member. Coordinate the core and Flutter integration release before publishing; this source change does not assign a release version. * Retain the first accepted flag-installation event for early and late registrations. Every active registration is delivered once in a microtask; its unregister function suppresses delivery not yet started. diff --git a/packages/datadog_flags/README.md b/packages/datadog_flags/README.md index 1f41e1d59..6d0f18a9c 100644 --- a/packages/datadog_flags/README.md +++ b/packages/datadog_flags/README.md @@ -370,7 +370,7 @@ Register after obtaining the client, before or after initializing its context: ```dart final client = DatadogFlags.instance.sharedClient(); -final unregister = client.events.onFirstFlags((event) { +final unregister = client.onFirstFlags((event) { print('First installed flags: ${event.flagsChanged}'); final details = client.getBooleanDetails( key: 'checkout.enabled', @@ -403,17 +403,12 @@ work and errors started by a void callback. Pending callbacks remain until an accepted install or unregistration. Reacquire a shared client after SDK re-enable; registrations do not migrate between core clients. -See the actual [typed CLI example](example/bin/typed_evaluation.dart). The -`DatadogFlagsClientEvents` extension creates a `DatadogFlagsEvents` facade; -`DatadogFlagsEvents(client)` is equivalent. The facade retains its client and -owns no event state. Discarding it does not cancel registrations. - -No member is added to `DatadogFlagsClient`, so existing implementations remain -source-compatible. Facade construction throws `ArgumentError` for unsupported -custom clients and opaque wrappers; retain the original SDK client when wrapping -evaluations. The SDK no-op client accepts registrations without emitting events. -Extensions require a statically typed client and the exported extension in scope; -they are not dynamically dispatched. The release version is coordinated separately. +See the actual [typed CLI example](example/bin/typed_evaluation.dart). +`onFirstFlags` is a member of `DatadogFlagsClient`. Custom implementations, +decorators, and test doubles must implement it; decorators can forward directly +to their delegate. This is a source-breaking interface addition. The SDK no-op +client accepts registrations without emitting events. The release version is +coordinated separately. VM-only capture-release checks use actual garbage collection through the local VM service: `dart test test_vm/first_flags_capture_test.dart`, or diff --git a/packages/datadog_flags/example/bin/typed_evaluation.dart b/packages/datadog_flags/example/bin/typed_evaluation.dart index b80304a38..004f9683b 100644 --- a/packages/datadog_flags/example/bin/typed_evaluation.dart +++ b/packages/datadog_flags/example/bin/typed_evaluation.dart @@ -45,7 +45,7 @@ Future main(List arguments) async { ); final flags = datadogFlags.sharedClient(); - final unregister = flags.events.onFirstFlags((event) { + final unregister = flags.onFirstFlags((event) { stdout.writeln('First installed flags: ${event.flagsChanged}'); _printDetails(_evaluate(flags, flagKey, flagType)); }); diff --git a/packages/datadog_flags/lib/datadog_flags.dart b/packages/datadog_flags/lib/datadog_flags.dart index 68b68c238..ab0c4f27e 100644 --- a/packages/datadog_flags/lib/datadog_flags.dart +++ b/packages/datadog_flags/lib/datadog_flags.dart @@ -22,6 +22,3 @@ export 'src/evaluation_context.dart' show FlagsEvaluationContext; export 'src/flags_client_event.dart' show FlagsClientEvent, FlagsClientEventType; - -export 'src/flags_events.dart' - show DatadogFlagsEvents, DatadogFlagsClientEvents; diff --git a/packages/datadog_flags/lib/datadog_flags_internal.dart b/packages/datadog_flags/lib/datadog_flags_internal.dart deleted file mode 100644 index e282a521a..000000000 --- a/packages/datadog_flags/lib/datadog_flags_internal.dart +++ /dev/null @@ -1,8 +0,0 @@ -// 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. - -// For Datadog companion packages only, not for application use. -// This bridge can change without notice; it is not a supported custom-client API. -export 'src/flags_event_registry.dart' show registerFlagsEventSource; diff --git a/packages/datadog_flags/lib/src/default_flags_client.dart b/packages/datadog_flags/lib/src/default_flags_client.dart index 37ff5e4f9..1c8c5da58 100644 --- a/packages/datadog_flags/lib/src/default_flags_client.dart +++ b/packages/datadog_flags/lib/src/default_flags_client.dart @@ -9,7 +9,7 @@ import 'evaluation_context.dart'; import 'exposure_logger.dart'; import 'flags_client.dart'; import 'flags_error.dart'; -import 'flags_event_registry.dart'; +import 'flags_client_event.dart'; import 'flags_repository.dart'; class DefaultDatadogFlagsClient implements DatadogFlagsClient { @@ -28,9 +28,12 @@ class DefaultDatadogFlagsClient implements DatadogFlagsClient { required EvaluationAggregator evaluationAggregator, }) : _repository = repository, _exposureLogger = exposureLogger, - _evaluationAggregator = evaluationAggregator { - registerFlagsEventSource(this, _repository.onFirstFlags); - } + _evaluationAggregator = evaluationAggregator; + + @override + void Function() onFirstFlags( + void Function(FlagsClientEvent event) listener) => + _repository.onFirstFlags(listener); @override Future initialize(FlagsEvaluationContext context) async { diff --git a/packages/datadog_flags/lib/src/flags_client.dart b/packages/datadog_flags/lib/src/flags_client.dart index fb18e8996..074064e55 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,28 @@ abstract interface class DatadogFlagsClient { /// Stable name assigned by [DatadogFlags.sharedClient]. String get name; + /// Registers [listener] for this client's first accepted cache or network + /// installation, including an empty configuration. Every registration receives + /// the retained first event once, even if registered after later updates. + /// Missing, invalid or rejected data does not complete this notification. + /// + /// Delivery always runs in a microtask in the registration zone, including + /// late registrations. It does not wait for initialization or persistence to + /// complete. Evaluations in the callback read current assignments, not a + /// snapshot pinned to the event. + /// Synchronous callback exceptions are isolated; asynchronous work and errors + /// started by the callback belong to the application. + /// + /// Returns an idempotent unregister function. It releases the callback and + /// suppresses delivery that has not started, including an already queued + /// microtask. It cannot interrupt a running callback, clear the retained event + /// or cancel initialization. Reset does not rearm or erase the first event. + /// Pending callbacks remain until installation or explicit unregistration. + /// Reacquire a shared client after SDK re-enable; registrations do not migrate. + 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 diff --git a/packages/datadog_flags/lib/src/flags_event_registry.dart b/packages/datadog_flags/lib/src/flags_event_registry.dart deleted file mode 100644 index 110cc728b..000000000 --- a/packages/datadog_flags/lib/src/flags_event_registry.dart +++ /dev/null @@ -1,34 +0,0 @@ -// 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 'flags_client.dart'; -import 'flags_client_event.dart'; - -// Weak identity association: the registry does not keep clients (or callbacks -// bound to those clients) alive. No application listener is stored here. -final _sources = - Expando( - 'flags events'); - -/// SDK companion-package bridge, not an application extension point. -void registerFlagsEventSource( - DatadogFlagsClient client, - void Function() Function(void Function(FlagsClientEvent)) register, -) { - if (_sources[client] != null) { - throw StateError('Flags event source is already registered'); - } - _sources[client] = register; -} - -void Function() Function(void Function(FlagsClientEvent)) flagsEventSource( - DatadogFlagsClient client, -) { - final source = _sources[client]; - if (source == null) { - throw ArgumentError('Events require an SDK-created Datadog flags client'); - } - return source; -} diff --git a/packages/datadog_flags/lib/src/flags_events.dart b/packages/datadog_flags/lib/src/flags_events.dart deleted file mode 100644 index 03b9bac62..000000000 --- a/packages/datadog_flags/lib/src/flags_events.dart +++ /dev/null @@ -1,53 +0,0 @@ -// 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 'flags_client.dart'; -import 'flags_client_event.dart'; -import 'flags_event_registry.dart'; - -/// Event access for a native Datadog core or Flutter flags client. -/// -/// This lightweight facade retains its client, but owns no event or registration -/// state. Discarding it does not unregister callbacks; use the returned function. -final class DatadogFlagsEvents { - final DatadogFlagsClient _client; - - /// Creates event access for an SDK client, including the SDK's no-op client. - /// - /// Throws [ArgumentError] immediately for custom implementations and opaque - /// wrappers. Keep the original SDK client when wrapping evaluations yourself. - /// The SDK no-op client accepts registration without delivering an event. - DatadogFlagsEvents(DatadogFlagsClient client) : _client = client { - flagsEventSource(client); - } - - /// Registers [listener] for this client's first accepted cache or network - /// installation, including an empty configuration. Every registration receives - /// the retained first event once, even if registered after later updates. - /// Missing, invalid or rejected data does not complete this notification. - /// - /// Delivery always runs in a microtask in the registration zone, including - /// late registrations. It does - /// not wait for initialization or persistence to complete. Evaluations in the - /// callback read current assignments, not a snapshot pinned to the event. - /// Synchronous callback exceptions are isolated; asynchronous work and errors - /// started by the callback belong to the application. - /// - /// Returns an idempotent unregister function. It releases the callback and - /// suppresses delivery that has not started, including an already queued - /// microtask. It cannot interrupt a running callback, clear the retained event - /// or cancel initialization. Reset does not rearm or erase the first event. - /// Pending callbacks remain until installation or explicit unregistration. - /// Reacquire a shared client after SDK re-enable; registrations do not migrate. - void Function() onFirstFlags( - void Function(FlagsClientEvent event) listener) => - flagsEventSource(_client)(listener); -} - -/// Event access without adding members to the evaluation client interface. -extension DatadogFlagsClientEvents on DatadogFlagsClient { - /// Creates a lightweight events facade; throws for unsupported custom clients. - DatadogFlagsEvents get events => DatadogFlagsEvents(this); -} 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 85afaff7d..9fb380975 100644 --- a/packages/datadog_flags/lib/src/no_op_flags_client.dart +++ b/packages/datadog_flags/lib/src/no_op_flags_client.dart @@ -7,15 +7,17 @@ import 'evaluation_context.dart'; import 'flags_client.dart'; import 'flags_error.dart'; import 'flags_client_event.dart'; -import 'flags_event_registry.dart'; class NoOpDatadogFlagsClient implements DatadogFlagsClient { @override final String name; - NoOpDatadogFlagsClient({required this.name}) { - registerFlagsEventSource(this, _noOpRegistration); - } + NoOpDatadogFlagsClient({required this.name}); + + @override + void Function() onFirstFlags( + void Function(FlagsClientEvent event) listener) => + () {}; @override Future initialize(FlagsEvaluationContext context) async {} @@ -77,6 +79,3 @@ class NoOpDatadogFlagsClient implements DatadogFlagsClient { ); } } - -void Function() _noOpRegistration(void Function(FlagsClientEvent) listener) => - () {}; diff --git a/packages/datadog_flags/test/first_flags_callback_test.dart b/packages/datadog_flags/test/first_flags_callback_test.dart index 66bf100e7..c9b038f13 100644 --- a/packages/datadog_flags/test/first_flags_callback_test.dart +++ b/packages/datadog_flags/test/first_flags_callback_test.dart @@ -71,7 +71,7 @@ Future _owner({ initializationTimeout: timeout, httpClient: MockClient(request ?? (_) async => _response()), )); - flags.sharedClient().events.onFirstFlags(callback); + flags.sharedClient().onFirstFlags(callback); addTearDown(flags.disable); return flags; } @@ -216,7 +216,7 @@ void main() { final flags = await _owner(callback: events.add); await flags.sharedClient().initialize(_context); await flags.sharedClient().initialize(_context); - flags.sharedClient(name: 'other').events.onFirstFlags(events.add); + flags.sharedClient(name: 'other').onFirstFlags(events.add); await flags.sharedClient(name: 'other').initialize(_context); expect(events, hasLength(2)); }); diff --git a/packages/datadog_flags/test/first_flags_registration_test.dart b/packages/datadog_flags/test/first_flags_registration_test.dart index 1468e5dec..f8ae7ba88 100644 --- a/packages/datadog_flags/test/first_flags_registration_test.dart +++ b/packages/datadog_flags/test/first_flags_registration_test.dart @@ -51,7 +51,7 @@ void main() { Object? observed; final errors = []; runZonedGuarded(() { - client.events.onFirstFlags((_) { + client.onFirstFlags((_) { observed = Zone.current[#listenerZone]; scheduleMicrotask(() => throw StateError('async listener error')); throw StateError('isolated synchronous listener error'); @@ -74,14 +74,14 @@ void main() { () async { final client = (await _owner()).sharedClient(); final events = []; - client.events.onFirstFlags(events.add); - client.events.onFirstFlags(events.add); + 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.events.onFirstFlags(events.add); + client.onFirstFlags(events.add); expect(events, hasLength(2)); await _flush(); expect(events, hasLength(3)); @@ -91,13 +91,13 @@ void main() { final client = (await _owner()).sharedClient(); var calls = 0; void callback(FlagsClientEvent _) => calls++; - final cancel = client.events.onFirstFlags(callback); - client.events.onFirstFlags(callback); + final cancel = client.onFirstFlags(callback); + client.onFirstFlags(callback); cancel(); cancel(); await client.initialize(_context); expect(calls, 1); - client.events.onFirstFlags(callback); + client.onFirstFlags(callback); await _flush(); expect(calls, 2); }); @@ -105,12 +105,12 @@ void main() { final client = (await _owner()).sharedClient(); await client.initialize(_context); var calls = 0; - final cancel = client.events.onFirstFlags((_) => calls++); + final cancel = client.onFirstFlags((_) => calls++); expect(calls, 0); cancel(); await _flush(); expect(calls, 0); - final after = client.events.onFirstFlags((_) => calls++); + final after = client.onFirstFlags((_) => calls++); await _flush(); after(); after(); @@ -123,12 +123,12 @@ void main() { var calls = 0; late void Function() second; late void Function() first; - first = client.events.onFirstFlags((_) { + first = client.onFirstFlags((_) { first(); second(); calls++; }); - second = client.events.onFirstFlags((_) => calls += 100); + second = client.onFirstFlags((_) => calls += 100); await client.initialize(_context); expect(calls, 1); }); @@ -136,13 +136,13 @@ void main() { () async { final client = (await _owner()).sharedClient(); final order = []; - client.events.onFirstFlags((_) { + client.onFirstFlags((_) { order.add('first'); - client.events.onFirstFlags((_) => order.add('nested')); + client.onFirstFlags((_) => order.add('nested')); expect(order, ['first']); throw StateError('app'); }); - client.events.onFirstFlags((_) => order.add('second')); + client.onFirstFlags((_) => order.add('second')); await client.initialize(_context); await _flush(); expect(order, ['first', 'second', 'nested']); @@ -151,11 +151,11 @@ void main() { () async { final client = (await _owner()).sharedClient(); var canceledCalls = 0; - final cancel = client.events.onFirstFlags((_) => canceledCalls++); + final cancel = client.onFirstFlags((_) => canceledCalls++); cancel(); await client.initialize(_context); final delivered = Completer(); - client.events.onFirstFlags(delivered.complete); + client.onFirstFlags(delivered.complete); expect((await delivered.future).flagsChanged, ['checkout.enabled']); expect(canceledCalls, 0); expect( @@ -172,7 +172,7 @@ void main() { await client.reset(); await client.shutdown(); final delivered = Completer(); - client.events.onFirstFlags(delivered.complete); + client.onFirstFlags(delivered.complete); expect(delivered.isCompleted, isFalse); expect((await delivered.future).flagsChanged, ['checkout.enabled']); expect( @@ -185,7 +185,7 @@ void main() { final owner = DatadogFlags(); final client = owner.sharedClient(); var calls = 0; - final cancel = client.events.onFirstFlags((_) => calls++); + final cancel = client.onFirstFlags((_) => calls++); await client.initialize(_context); cancel(); cancel(); diff --git a/packages/datadog_flags/test/flags_client_interface_test.dart b/packages/datadog_flags/test/flags_client_interface_test.dart new file mode 100644 index 000000000..e971390c0 --- /dev/null +++ b/packages/datadog_flags/test/flags_client_interface_test.dart @@ -0,0 +1,67 @@ +// 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'; + +// Implements the complete public interface without Fake/noSuchMethod. +class CustomClient implements DatadogFlagsClient { + void Function(FlagsClientEvent)? listener; + + @override + void Function() onFirstFlags(void Function(FlagsClientEvent event) callback) { + listener = callback; + return () => listener = null; + } + + @override + String get name => 'custom'; + @override + Future initialize(FlagsEvaluationContext context) async {} + @override + Future reset() async {} + @override + Future shutdown() async {} + @override + FlagDetails getBooleanDetails( + {required String key, required bool defaultValue}) => + FlagDetails(key: key, value: defaultValue); + @override + FlagDetails getStringDetails( + {required String key, required String defaultValue}) => + FlagDetails(key: key, value: defaultValue); + @override + FlagDetails getIntegerDetails( + {required String key, required int defaultValue}) => + FlagDetails(key: key, value: defaultValue); + @override + FlagDetails getDoubleDetails( + {required String key, required double defaultValue}) => + FlagDetails(key: key, value: defaultValue); + @override + FlagDetails getObjectDetails( + {required String key, required Object? defaultValue}) => + FlagDetails(key: key, value: defaultValue); +} + +void main() { + test( + 'custom implementor supplies callbacks through the public client interface', + () { + final custom = CustomClient(); + final DatadogFlagsClient client = custom; + final events = []; + final unregister = client.onFirstFlags(events.add); + final event = + FlagsClientEvent(type: FlagsClientEventType.configurationChanged); + custom.listener!(event); + expect(events.single, same(event)); + unregister(); + unregister(); + expect(custom.listener, isNull); + expect(client.getBooleanDetails(key: 'flag', defaultValue: true).value, + isTrue); + }); +} diff --git a/packages/datadog_flags/test/flags_events_compatibility_test.dart b/packages/datadog_flags/test/flags_events_compatibility_test.dart deleted file mode 100644 index 1c5f15f18..000000000 --- a/packages/datadog_flags/test/flags_events_compatibility_test.dart +++ /dev/null @@ -1,86 +0,0 @@ -// 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'; - -// Deliberately implements every original member without Fake/noSuchMethod. -class LegacyClient implements DatadogFlagsClient { - @override - String get name => 'legacy'; - @override - Future initialize(FlagsEvaluationContext context) async {} - @override - Future reset() async {} - @override - Future shutdown() async {} - @override - FlagDetails getBooleanDetails( - {required String key, required bool defaultValue}) => - FlagDetails(key: key, value: defaultValue); - @override - FlagDetails getStringDetails( - {required String key, required String defaultValue}) => - FlagDetails(key: key, value: defaultValue); - @override - FlagDetails getIntegerDetails( - {required String key, required int defaultValue}) => - FlagDetails(key: key, value: defaultValue); - @override - FlagDetails getDoubleDetails( - {required String key, required double defaultValue}) => - FlagDetails(key: key, value: defaultValue); - @override - FlagDetails getObjectDetails( - {required String key, required Object? defaultValue}) => - FlagDetails(key: key, value: defaultValue); -} - -class OpaqueClient extends LegacyClient { - final DatadogFlagsClient delegate; - OpaqueClient(this.delegate); - @override - String get name => delegate.name; -} - -class ClientWithEvents extends LegacyClient { - String get events => 'custom'; -} - -void main() { - test('opaque wrapper is rejected even when it holds a native client', () { - final client = OpaqueClient(DatadogFlags().sharedClient()); - expect(() => DatadogFlagsEvents(client), throwsArgumentError); - }); - test('SDK no-op accepts both access forms without fabricating an event', - () async { - final client = DatadogFlags().sharedClient(); - final first = client.events; - final second = DatadogFlagsEvents(client); - expect(identical(first, second), isFalse); - var calls = 0; - final cancel = first.onFirstFlags((_) => calls++); - second.onFirstFlags((_) => calls++); - cancel(); - cancel(); - await Future.delayed(Duration.zero); - expect(calls, 0); - }); - test( - 'custom instance member wins; explicit extension still validates support', - () { - final client = ClientWithEvents(); - expect(client.events, 'custom'); - expect(() => DatadogFlagsClientEvents(client).events, throwsArgumentError); - }); - test('legacy implementor compiles but has no implicit event capability', () { - final client = LegacyClient(); - expect(client.getBooleanDetails(key: 'flag', defaultValue: true).value, - isTrue); - expect(() => DatadogFlagsEvents(client), throwsArgumentError); - expect(() => client.events, throwsArgumentError); - expect(() => DatadogFlagsClientEvents(client).events, throwsArgumentError); - }); -} diff --git a/packages/datadog_flags/test_vm/first_flags_capture_test.dart b/packages/datadog_flags/test_vm/first_flags_capture_test.dart index 720dc13af..d88c9d1e4 100644 --- a/packages/datadog_flags/test_vm/first_flags_capture_test.dart +++ b/packages/datadog_flags/test_vm/first_flags_capture_test.dart @@ -17,43 +17,12 @@ import 'helpers/force_gc.dart'; (WeakReference, void Function()) register(DatadogFlagsClient client) { final capture = Capture(); final reference = WeakReference(capture); - final unregister = runZoned( - () => client.events.onFirstFlags((_) => capture.use()), + final unregister = runZoned(() => client.onFirstFlags((_) => capture.use()), zoneValues: {#registration: 'capture-test'}); return (reference, unregister); } -@pragma('vm:never-inline') -WeakReference registerThroughFacade( - DatadogFlagsClient client, void Function(FlagsClientEvent) callback) { - final events = client.events; - events.onFirstFlags(callback); - return WeakReference(events); -} - void main() { - test('collecting facade does not cancel client-owned pending registration', - () 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 client = owner.sharedClient(); - final delivered = Completer(); - final facade = registerThroughFacade(client, delivered.complete); - await collectGarbage(); - expect(hasTarget(facade), isFalse); - await client.initialize(FlagsEvaluationContext.empty); - expect((await delivered.future).flagsChanged, isEmpty); - final replay = Completer(); - DatadogFlagsEvents(client).onFirstFlags(replay.complete); - expect(await replay.future, same(await delivered.future)); - }); for (final queued in [false, true]) { test( 'cancel releases ${queued ? 'queued' : 'pending'} captures while token remains live', diff --git a/packages/datadog_flags_flutter/CHANGELOG.md b/packages/datadog_flags_flutter/CHANGELOG.md index 229237902..4ea287ef3 100644 --- a/packages/datadog_flags_flutter/CHANGELOG.md +++ b/packages/datadog_flags_flutter/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -* Add `DatadogFlagsEvents` and the `DatadogFlagsClientEvents.events` extension for retained first-install callbacks with an unregister function. Existing client implementations remain source-compatible; unsupported custom clients are rejected when creating the facade. Coordinate the core and Flutter integration release before publishing; this source change does not assign a release version. +* Add `onFirstFlags` directly to `DatadogFlagsClient` for retained first-install callbacks with a registration-local unregister function. Custom implementations and test doubles must implement the new member. Coordinate the core and Flutter integration release before publishing; this source change does not assign a release version. * Retain the first accepted flag-installation event for early and late registrations. Every active registration is delivered once in a microtask; its unregister function suppresses delivery not yet started. diff --git a/packages/datadog_flags_flutter/README.md b/packages/datadog_flags_flutter/README.md index b8947bd80..5d2890730 100644 --- a/packages/datadog_flags_flutter/README.md +++ b/packages/datadog_flags_flutter/README.md @@ -128,7 +128,7 @@ Pull requests are welcome. For more information, read the ## First installed flags -Call `client.events.onFirstFlags((event) { ... })` after obtaining a shared client. It +Call `client.onFirstFlags((event) { ... })` after obtaining a shared client. It returns an ordinary unregister function; there is no subscription object. The callback receives only `FlagsClientEvent` and always runs in a microtask, including when the first event is already retained. The wrapper resolves its @@ -155,14 +155,12 @@ Run the VM-only capture-release proof with ### Coordinated release requirement -The facade adds no members to `DatadogFlagsClient` or -`DatadogFlutterFlagsClient`; existing implementations and fakes remain -source-compatible. Both the concrete facade and its named extension are exported -from this package. Facade construction throws `ArgumentError` for unsupported -custom clients or opaque wrappers. Keep the original SDK client when wrapping -evaluations; the SDK no-op client remains supported without notification. -A facade retains its client, but discarding it never cancels a registration. -The wrapper's internal asynchronous delegate resolution remains best effort. +`onFirstFlags` is a member of `DatadogFlagsClient` and +`DatadogFlutterFlagsClient`. Custom implementations, decorators, and test doubles +must implement it; decorators can forward directly to their delegate. This is a +source-breaking interface addition. The SDK no-op client accepts registrations +without emitting events. The wrapper's asynchronous delegate resolution remains +best effort. The repository releaser assigns an explicit release version; this change does not select one. Before publishing the integration, publish the coordinated diff --git a/packages/datadog_flags_flutter/example/lib/main.dart b/packages/datadog_flags_flutter/example/lib/main.dart index 8e8747614..244659bd3 100644 --- a/packages/datadog_flags_flutter/example/lib/main.dart +++ b/packages/datadog_flags_flutter/example/lib/main.dart @@ -80,7 +80,7 @@ class _FlagsExampleAppState extends State { super.initState(); final client = DatadogSdk.instance.flags?.sharedClient(); if (widget.isConfigured && client != null) { - _unregisterFirstFlags = client.events.onFirstFlags((event) { + _unregisterFirstFlags = client.onFirstFlags((event) { debugPrint('First installed flags: ${event.flagsChanged}'); final details = client.getBooleanDetails( key: _flagKey, diff --git a/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart b/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart index a28270cdc..ba95d5329 100644 --- a/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart +++ b/packages/datadog_flags_flutter/lib/datadog_flags_flutter.dart @@ -7,8 +7,6 @@ export 'package:datadog_flags/datadog_flags.dart' show DatadogFlagsConfig, DatadogFlagsClient, - DatadogFlagsEvents, - DatadogFlagsClientEvents, DatadogFlagsConfiguration, FlagsClientEvent, FlagsClientEventType, 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 e24f05b14..67e96dab6 100644 --- a/packages/datadog_flags_flutter/lib/src/datadog_flags_plugin.dart +++ b/packages/datadog_flags_flutter/lib/src/datadog_flags_plugin.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'package:datadog_flags/datadog_flags.dart'; -import 'package:datadog_flags/datadog_flags_internal.dart'; import 'package:datadog_flutter_plugin/datadog_flutter_plugin.dart'; import 'package:flutter/foundation.dart'; @@ -183,15 +182,14 @@ class DatadogFlutterFlagsClient implements DatadogFlagsClient { required void Function(String key, Object value)? addRumFeatureFlagEvaluation, }) : _resolveDelegate = resolveDelegate, - _addRumFeatureFlagEvaluation = addRumFeatureFlagEvaluation { - registerFlagsEventSource(this, _onFirstFlags); - } + _addRumFeatureFlagEvaluation = addRumFeatureFlagEvaluation; /// Always delivers in a microtask after binding the existing core delegate. /// Cancellation works immediately, including while that delegate is resolving. /// Each registration forwards once to its resolved core; there is no retry or /// migration across re-enable. Reacquire a shared client after re-enable. - void Function() _onFirstFlags(void Function(FlagsClientEvent) callback) { + @override + void Function() onFirstFlags(void Function(FlagsClientEvent) callback) { final registration = _FlutterFirstFlagsRegistration(callback); unawaited(_forwardFirstFlags(registration)); return registration.cancel; @@ -203,7 +201,7 @@ class DatadogFlutterFlagsClient implements DatadogFlagsClient { try { final delegate = await _delegateOrResolve(); if (!registration.isActive) return; - final unregister = delegate.events.onFirstFlags(registration.schedule); + final unregister = delegate.onFirstFlags(registration.schedule); registration.attach(unregister); } catch (_) { // Registration is best effort; initialization retains its own error path. diff --git a/packages/datadog_flags_flutter/test/first_flags_registration_test.dart b/packages/datadog_flags_flutter/test/first_flags_registration_test.dart index 7dc6cbbf6..2024b7d62 100644 --- a/packages/datadog_flags_flutter/test/first_flags_registration_test.dart +++ b/packages/datadog_flags_flutter/test/first_flags_registration_test.dart @@ -6,7 +6,6 @@ import 'dart:async'; import 'dart:convert'; import 'package:datadog_flags/datadog_flags.dart'; -import 'package:datadog_flags/datadog_flags_internal.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'; @@ -24,16 +23,14 @@ class _Sdk extends Mock implements DatadogSdk {} class _Rum extends Mock implements DatadogRum {} class _Delegate extends Fake implements DatadogFlagsClient { - _Delegate() { - registerFlagsEventSource(this, _register); - } int registrations = 0; int initializations = 0; int cancellations = 0; void Function(FlagsClientEvent)? listener; void Function()? duringRegistration; bool throwCancel = false; - void Function() _register(void Function(FlagsClientEvent) callback) { + @override + void Function() onFirstFlags(void Function(FlagsClientEvent) callback) { registrations++; listener = callback; duringRegistration?.call(); @@ -64,7 +61,7 @@ void main() { Object? observed; final errors = []; runZonedGuarded(() { - client.events.onFirstFlags((_) { + client.onFirstFlags((_) { observed = Zone.current[#listenerZone]; scheduleMicrotask(() => throw StateError('async listener error')); throw StateError('isolated synchronous listener error'); @@ -86,7 +83,7 @@ void main() { final core = _Delegate(); final client = createFirstFlagsTestClient(() => resolving.future); var calls = 0; - final cancel = client.events.onFirstFlags((_) => calls++); + final cancel = client.onFirstFlags((_) => calls++); cancel(); cancel(); resolving.complete(core); @@ -99,7 +96,7 @@ void main() { final resolving = Completer(); final client = createFirstFlagsTestClient(() => resolving.future); var calls = 0; - final cancel = client.events.onFirstFlags((_) => calls++); + final cancel = client.onFirstFlags((_) => calls++); resolving.completeError(StateError('enable failed')); await _flush(); cancel(); @@ -113,7 +110,7 @@ void main() { final core = _Delegate(); final client = createFirstFlagsTestClient(() async => core); var calls = 0; - final cancel = client.events.onFirstFlags((_) => calls++); + final cancel = client.onFirstFlags((_) => calls++); await _flush(); core.listener!(_event); expect(calls, 0); @@ -136,7 +133,7 @@ void main() { core.listener!(_event); duringCalls = calls; }; - final cancel = client.events.onFirstFlags((_) => calls++); + final cancel = client.onFirstFlags((_) => calls++); expect(calls, 0); await _flush(); expect(calls, 1); @@ -155,7 +152,7 @@ void main() { core.listener!(_event); cancel(); }; - cancel = client.events.onFirstFlags((_) => calls++); + cancel = client.onFirstFlags((_) => calls++); await _flush(); cancel(); expect(core.cancellations, 1); @@ -164,7 +161,7 @@ void main() { test('callback exception does not produce failed SDK Future', () async { final core = _Delegate(); final client = createFirstFlagsTestClient(() async => core); - client.events.onFirstFlags((_) => throw StateError('app')); + client.onFirstFlags((_) => throw StateError('app')); await _flush(); core.listener!(_event); await _flush(); @@ -180,7 +177,7 @@ void main() { return resolving.future; }); var calls = 0; - final cancel = client.events.onFirstFlags((_) => calls++); + final cancel = client.onFirstFlags((_) => calls++); await client.shutdown(); resolving.complete(coreA); await _flush(); @@ -192,7 +189,7 @@ void main() { expect(calls, 1); cancel(); final replacement = createFirstFlagsTestClient(() async => coreB); - final cancelB = replacement.events.onFirstFlags((_) {}); + final cancelB = replacement.onFirstFlags((_) {}); await _flush(); expect(coreB.registrations, 1); cancelB(); @@ -238,7 +235,7 @@ void main() { final client = plugin.sharedClient(); final events = []; FlagDetails? details; - client.events.onFirstFlags((event) { + client.onFirstFlags((event) { events.add(event); details = client.getBooleanDetails( key: 'checkout.enabled', defaultValue: false); @@ -251,7 +248,7 @@ void main() { expect(requests, 1); verify(() => rum.addFeatureFlagEvaluation('checkout.enabled', 'enabled')) .called(1); - final cancel = client.events.onFirstFlags(events.add); + final cancel = client.onFirstFlags(events.add); cancel(); await _flush(); expect(events, hasLength(1)); @@ -262,7 +259,7 @@ void main() { final core = _Delegate(); final client = createFirstFlagsTestClient(() => resolving.future); var calls = 0; - client.events.onFirstFlags((_) => calls++); + client.onFirstFlags((_) => calls++); expect(core.registrations, 0); resolving.complete(core); await _flush(); diff --git a/packages/datadog_flags_flutter/test/flags_events_compatibility_test.dart b/packages/datadog_flags_flutter/test/flags_client_interface_test.dart similarity index 63% rename from packages/datadog_flags_flutter/test/flags_events_compatibility_test.dart rename to packages/datadog_flags_flutter/test/flags_client_interface_test.dart index 9304c2ab4..dc51f2eee 100644 --- a/packages/datadog_flags_flutter/test/flags_events_compatibility_test.dart +++ b/packages/datadog_flags_flutter/test/flags_client_interface_test.dart @@ -6,10 +6,18 @@ import 'package:datadog_flags_flutter/datadog_flags_flutter.dart'; import 'package:flutter_test/flutter_test.dart'; -// Deliberately implements every original member without Fake/noSuchMethod. -class LegacyClient implements DatadogFlagsClient, DatadogFlutterFlagsClient { +// Implements the complete public interface without Fake/noSuchMethod. +class CustomClient implements DatadogFlagsClient, DatadogFlutterFlagsClient { + void Function(FlagsClientEvent)? listener; + + @override + void Function() onFirstFlags(void Function(FlagsClientEvent event) callback) { + listener = callback; + return () => listener = null; + } + @override - String get name => 'legacy'; + String get name => 'custom'; @override Future initialize(FlagsEvaluationContext context) async {} @override @@ -39,12 +47,21 @@ class LegacyClient implements DatadogFlagsClient, DatadogFlutterFlagsClient { } void main() { - test('legacy implementor compiles but has no implicit event capability', () { - final client = LegacyClient(); + test( + 'custom implementor supplies callbacks through the public client interface', + () { + final custom = CustomClient(); + final DatadogFlagsClient client = custom; + final events = []; + final unregister = client.onFirstFlags(events.add); + final event = + FlagsClientEvent(type: FlagsClientEventType.configurationChanged); + custom.listener!(event); + expect(events.single, same(event)); + unregister(); + unregister(); + expect(custom.listener, isNull); expect(client.getBooleanDetails(key: 'flag', defaultValue: true).value, isTrue); - expect(() => DatadogFlagsEvents(client), throwsArgumentError); - expect(() => client.events, throwsArgumentError); - expect(() => DatadogFlagsClientEvents(client).events, throwsArgumentError); }); } diff --git a/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart b/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart index 1b7dbab93..b2125eeaa 100644 --- a/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart +++ b/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'package:datadog_flags/datadog_flags.dart'; -import 'package:datadog_flags/datadog_flags_internal.dart'; import 'package:datadog_flags_flutter/datadog_flags_flutter.dart'; import 'package:flutter_test/flutter_test.dart'; import 'helpers/force_gc.dart'; @@ -18,53 +17,21 @@ import '../test/helpers/first_flags_test_client.dart'; final reference = WeakReference(capture); return ( reference, - runZoned(() => client.events.onFirstFlags((_) => capture.use()), + runZoned(() => client.onFirstFlags((_) => capture.use()), zoneValues: {#registration: 'capture-test'}) ); } class _Delegate extends Fake implements DatadogFlagsClient { - _Delegate() { - registerFlagsEventSource(this, _register); - } void Function(FlagsClientEvent)? listener; - void Function() _register(void Function(FlagsClientEvent) callback) { + @override + void Function() onFirstFlags(void Function(FlagsClientEvent) callback) { listener = callback; return () => listener = null; } } -@pragma('vm:never-inline') -WeakReference unownedClient() { - final client = - createFirstFlagsTestClient(() async => DatadogFlags().sharedClient()); - return WeakReference(client); -} - -@pragma('vm:never-inline') -(WeakReference, DatadogFlagsEvents) - retainedFacade() { - final client = - createFirstFlagsTestClient(() async => DatadogFlags().sharedClient()); - return (WeakReference(client), client.events); -} - void main() { - test( - 'weak bridge does not retain wrapper through its bound registration method', - () async { - final client = unownedClient(); - await collectGarbage(); - expect(hasTarget(client), isFalse); - }); - test('live facade retains its client and remains usable after collection', - () async { - final pair = retainedFacade(); - await collectGarbage(); - expect(hasTarget(pair.$1), isTrue); - final cancel = pair.$2.onFirstFlags((_) => fail('no-op must not deliver')); - cancel(); - }); test('cancel releases forwarded capture while app microtask remains queued', () async { final core = _Delegate(); From 7a70427d6a0414474d231b37ad3bc72ae287c7d0 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Tue, 6 Oct 2026 12:32:15 -0600 Subject: [PATCH 5/5] Mark event construction internal and strengthen first flags coverage --- packages/datadog_flags/CHANGELOG.md | 2 + packages/datadog_flags/README.md | 33 ++- .../datadog_flags/lib/src/flags_client.dart | 13 +- .../lib/src/flags_client_event.dart | 10 +- packages/datadog_flags/pubspec.yaml | 2 +- .../test/first_flags_callback_test.dart | 190 +++++++++++++++++- .../test/first_flags_registration_test.dart | 31 +-- packages/datadog_flags_flutter/CHANGELOG.md | 2 + packages/datadog_flags_flutter/README.md | 30 ++- .../test/first_flags_registration_test.dart | 7 + .../test/flags_client_interface_test.dart | 2 + .../test_vm/first_flags_capture_test.dart | 5 +- 12 files changed, 277 insertions(+), 50 deletions(-) diff --git a/packages/datadog_flags/CHANGELOG.md b/packages/datadog_flags/CHANGELOG.md index 72f229bfe..1c1471067 100644 --- a/packages/datadog_flags/CHANGELOG.md +++ b/packages/datadog_flags/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +* Mark `FlagsClientEvent` construction as SDK-internal; applications receive events through `onFirstFlags`. + * Add `onFirstFlags` directly to `DatadogFlagsClient` for retained first-install callbacks with a registration-local unregister function. Custom implementations and test doubles must implement the new member. Coordinate the core and Flutter integration release before publishing; this source change does not assign a release version. * Retain the first accepted flag-installation event for early and late registrations. Every active registration is delivered once in a microtask; its unregister function suppresses delivery not yet started. diff --git a/packages/datadog_flags/README.md b/packages/datadog_flags/README.md index 6d0f18a9c..9ac7f1a73 100644 --- a/packages/datadog_flags/README.md +++ b/packages/datadog_flags/README.md @@ -381,24 +381,37 @@ final unregister = client.onFirstFlags((event) { // Call unregister() when the application no longer needs this notification. ``` -Each registration receives the retained first accepted cache/network event once, -including an empty installation. Missing, invalid, mismatched or rejected data -never fabricates completion. Multiple registrations are independent. Delivery -always runs in a microtask, including late registration; it does not wait for -initialization or persistence to complete and does not initialize the client. -Use the existing `initialize(context)` method and context/error behavior. +Each registration receives the retained first accepted cache/network event once. +This is one notification per registration, not one per flag or a subscription to +later updates. Its keys are the complete set in the first accepted configuration, +not every flag on the server. Valid empty configurations notify with `[]`. +Missing/rejected cache entries, failed requests, and responses that cannot be +decoded do not consume the notification. Malformed individual assignments are +filtered before installation, so only accepted keys appear in the event. + +Delivery always runs in a microtask in the registration zone, including late +registration. It does not wait for initialization or persistence to complete. +Registration and replay perform no SDK I/O and do not initialize the client. +Callback code can itself evaluate flags or start other work. Use the existing +`initialize(context)` method and context/error behavior. `FlagsClientEvent` has only `type` and nullable `flagsChanged`. The implemented type is `FlagsClientEventType.configurationChanged` (`CONFIGURATION_CHANGED`). -Supplied key lists are copied and unmodifiable; null and empty remain distinct. -First-install events contain all installed keys, including `[]`. Evaluations -read current assignments, not an assignment snapshot pinned to the event. +Key lists are defensively copied and unmodifiable; null and empty remain distinct. +The event constructor is SDK-internal (`@internal`) and is not a supported +application API; applications receive events through `onFirstFlags`. +The retained event keeps the first installation's keys, even after updates or a +failed refresh. It is not an assignment snapshot: evaluations read current values. +Existing fetch fallback rules still apply; without matching stored assignments, +a failed update can leave evaluations returning defaults while the first event +remains available for replay. The returned `void Function()` unregisters only its registration. It is idempotent, releases pending callback captures and suppresses queued delivery that has not started. It cannot interrupt a running callback or cancel initialization. Reset does not clear or rearm the retained first event. -Synchronous callback exceptions are isolated; applications own any asynchronous +Synchronous thrown objects, including Dart `Exception` and `Error`, are isolated; +applications own any asynchronous work and errors started by a void callback. Pending callbacks remain until an accepted install or unregistration. Reacquire a shared client after SDK re-enable; registrations do not migrate between core clients. diff --git a/packages/datadog_flags/lib/src/flags_client.dart b/packages/datadog_flags/lib/src/flags_client.dart index 074064e55..d96c4806c 100644 --- a/packages/datadog_flags/lib/src/flags_client.dart +++ b/packages/datadog_flags/lib/src/flags_client.dart @@ -19,14 +19,19 @@ abstract interface class DatadogFlagsClient { /// Registers [listener] for this client's first accepted cache or network /// installation, including an empty configuration. Every registration receives /// the retained first event once, even if registered after later updates. - /// Missing, invalid or rejected data does not complete this notification. + /// The event contains every key in that accepted configuration, not one event + /// per flag or a catalog of every server-side flag. Valid empty configurations + /// notify with an empty key list. Missing/rejected cache entries and failed or + /// undecodable responses do not consume the notification. /// /// Delivery always runs in a microtask in the registration zone, including /// late registrations. It does not wait for initialization or persistence to /// complete. Evaluations in the callback read current assignments, not a - /// snapshot pinned to the event. - /// Synchronous callback exceptions are isolated; asynchronous work and errors - /// started by the callback belong to the application. + /// snapshot pinned to the event. Registration and replay perform no SDK I/O; + /// callback code may itself evaluate flags or start other work. + /// Synchronous thrown objects (including Exception and Error) are isolated; + /// asynchronous work and errors started by the callback belong to the + /// application. /// /// Returns an idempotent unregister function. It releases the callback and /// suppresses delivery that has not started, including an already queued diff --git a/packages/datadog_flags/lib/src/flags_client_event.dart b/packages/datadog_flags/lib/src/flags_client_event.dart index 331b11b30..692f734d7 100644 --- a/packages/datadog_flags/lib/src/flags_client_event.dart +++ b/packages/datadog_flags/lib/src/flags_client_event.dart @@ -21,14 +21,18 @@ final class FlagsClientEvent { /// The kind of event represented by this value. final FlagsClientEventType type; - /// A snapshot of the supplied flag keys. + /// 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; - /// Creates an event, copying [flagsChanged] into an unmodifiable list when - /// supplied. Later changes to the caller's list cannot change this value. + /// 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 diff --git a/packages/datadog_flags/pubspec.yaml b/packages/datadog_flags/pubspec.yaml index c809a6ef2..a6ca88832 100644 --- a/packages/datadog_flags/pubspec.yaml +++ b/packages/datadog_flags/pubspec.yaml @@ -15,7 +15,7 @@ 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: diff --git a/packages/datadog_flags/test/first_flags_callback_test.dart b/packages/datadog_flags/test/first_flags_callback_test.dart index c9b038f13..1af94b21c 100644 --- a/packages/datadog_flags/test/first_flags_callback_test.dart +++ b/packages/datadog_flags/test/first_flags_callback_test.dart @@ -115,14 +115,58 @@ void main() { for (final fromCache in [false, true]) { test('accepted empty ${fromCache ? 'cache' : 'network'} notifies with []', () async { - final events = []; + final delivered = Completer(); + final network = Completer(); final flags = await _owner( - callback: events.add, + callback: delivered.complete, store: fromCache ? _Store(() async => _cached(empty: true)) : null, - request: (_) async => _response({})); - await flags.sharedClient().initialize(_context); - expect(events.single.type, FlagsClientEventType.configurationChanged); - expect(events.single.flagsChanged, isEmpty); + 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); }); } @@ -147,16 +191,138 @@ void main() { }); } - test('failed initialization without installation does not notify', () async { + 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, - store: _Store(() async => throw FormatException('invalid cache')), - request: (_) async => throw StateError('offline')); + request: (_) async => http.Response( + jsonEncode({ + 'data': { + 'attributes': { + 'flags': { + 'first': _flag(true), + 'invalid': null, + 'second': _flag(false), + } + } + } + }), + 200)); await flags.sharedClient().initialize(_context); - expect(events, isEmpty); + 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(); @@ -190,6 +356,10 @@ void main() { 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( diff --git a/packages/datadog_flags/test/first_flags_registration_test.dart b/packages/datadog_flags/test/first_flags_registration_test.dart index f8ae7ba88..15d7e21a9 100644 --- a/packages/datadog_flags/test/first_flags_registration_test.dart +++ b/packages/datadog_flags/test/first_flags_registration_test.dart @@ -132,21 +132,24 @@ void main() { await client.initialize(_context); expect(calls, 1); }); - test('nested registration is another microtask and exceptions are isolated', - () async { - final client = (await _owner()).sharedClient(); - final order = []; - client.onFirstFlags((_) { - order.add('first'); - client.onFirstFlags((_) => order.add('nested')); - expect(order, ['first']); - throw StateError('app'); + 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 _flush(); + expect(order, ['first', 'second', 'nested']); }); - client.onFirstFlags((_) => order.add('second')); - await client.initialize(_context); - await _flush(); - expect(order, ['first', 'second', 'nested']); - }); + } test('cancel all does not cancel initialize or erase retained event', () async { final client = (await _owner()).sharedClient(); diff --git a/packages/datadog_flags_flutter/CHANGELOG.md b/packages/datadog_flags_flutter/CHANGELOG.md index 4ea287ef3..24f7d0e3b 100644 --- a/packages/datadog_flags_flutter/CHANGELOG.md +++ b/packages/datadog_flags_flutter/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +* Mark `FlagsClientEvent` construction as SDK-internal; applications receive events through `onFirstFlags`. + * Add `onFirstFlags` directly to `DatadogFlagsClient` for retained first-install callbacks with a registration-local unregister function. Custom implementations and test doubles must implement the new member. Coordinate the core and Flutter integration release before publishing; this source change does not assign a release version. * Retain the first accepted flag-installation event for early and late registrations. Every active registration is delivered once in a microtask; its unregister function suppresses delivery not yet started. diff --git a/packages/datadog_flags_flutter/README.md b/packages/datadog_flags_flutter/README.md index 5d2890730..4b3c50aad 100644 --- a/packages/datadog_flags_flutter/README.md +++ b/packages/datadog_flags_flutter/README.md @@ -130,16 +130,34 @@ Pull requests are welcome. For more information, read the Call `client.onFirstFlags((event) { ... })` after obtaining a shared client. It returns an ordinary unregister function; there is no subscription object. The -callback receives only `FlagsClientEvent` and always runs in a microtask, -including when the first event is already retained. The wrapper resolves its +callback receives only `FlagsClientEvent` once per registration and always runs +in a microtask in the registration zone, including when the first event is +already retained. The wrapper resolves its existing core delegate before forwarding registration, without initializing a context or starting an assignment fetch. Callback evaluations use the existing RUM-integrated wrapper and current assignments. -Unregister immediately releases the app callback and prevents delivery not yet -started, even while the delegate is resolving or a callback microtask is queued. -After forwarding, unregister invokes the core cancellation function. Each -registration forwards once to its resolved core; resolver failure does not +The event contains the complete key set of the first accepted configuration, +not one event per flag or every server-side flag. A valid empty configuration +notifies with `[]`; missing/rejected cache entries, failed requests, and responses +that cannot be decoded do not consume the notification. Individual malformed +assignments are filtered before installation. Keys are defensively copied and +unmodifiable. The event constructor is SDK-internal (`@internal`); applications +receive events through `onFirstFlags`. Retained keys describe the first +installation, not a snapshot of +current assignments; evaluations read current values and existing fallback rules +still apply after failed updates. Registration and replay perform no SDK I/O; +callback code may itself evaluate flags or start other work. + +Synchronous thrown objects, including Dart `Exception` and `Error`, are isolated. +Asynchronous work and errors started by the callback belong to the application. + +Unregister is idempotent. It immediately releases the app callback and prevents +delivery not yet started, even while the delegate is resolving or a callback +microtask is queued. +It cannot interrupt a running callback, cancel initialization, or erase/rearm +the retained event. After forwarding, it invokes the core cancellation function. +Each registration forwards once to its resolved core; resolver failure does not fabricate success or retry. Reacquire `sharedClient` after SDK re-enable; old wrappers have no new lifecycle guarantee and registrations do not migrate. diff --git a/packages/datadog_flags_flutter/test/first_flags_registration_test.dart b/packages/datadog_flags_flutter/test/first_flags_registration_test.dart index 2024b7d62..30e188d6e 100644 --- a/packages/datadog_flags_flutter/test/first_flags_registration_test.dart +++ b/packages/datadog_flags_flutter/test/first_flags_registration_test.dart @@ -14,6 +14,8 @@ 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']); @@ -252,6 +254,11 @@ void main() { cancel(); await _flush(); 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); }); test('registering before plugin readiness does not initialize flags', () async { diff --git a/packages/datadog_flags_flutter/test/flags_client_interface_test.dart b/packages/datadog_flags_flutter/test/flags_client_interface_test.dart index dc51f2eee..3bd8a1838 100644 --- a/packages/datadog_flags_flutter/test/flags_client_interface_test.dart +++ b/packages/datadog_flags_flutter/test/flags_client_interface_test.dart @@ -55,6 +55,8 @@ void main() { final events = []; final unregister = client.onFirstFlags(events.add); final event = + // Test fixture deliberately constructs an SDK-internal event. + // ignore: invalid_use_of_internal_member FlagsClientEvent(type: FlagsClientEventType.configurationChanged); custom.listener!(event); expect(events.single, same(event)); diff --git a/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart b/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart index b2125eeaa..e6ab87619 100644 --- a/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart +++ b/packages/datadog_flags_flutter/test_vm/first_flags_capture_test.dart @@ -39,8 +39,9 @@ void main() { final registration = register(client); await Future.delayed(Duration.zero); final held = []; - runZoned( - () => core.listener!( + runZoned(() => core.listener!( + // Test fixture deliberately constructs an SDK-internal event. + // ignore: invalid_use_of_internal_member FlagsClientEvent(type: FlagsClientEventType.configurationChanged)), zoneSpecification: ZoneSpecification(scheduleMicrotask: (self, parent, zone, action) {