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