Repository navigation
feat: add retained first flags callbacks directly to Dart and Flutter clients #1204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
61900f6
27f0621
8205054
b9dac5d
522ba3f
0cecd05
e406e12
7a70427
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -363,3 +363,66 @@ 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. | ||
| ``` | ||
|
|
||
| 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`). | ||
| 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. | ||
|
Comment on lines
+398
to
+407
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this all necessary to be in the README? This seems like it's going into way too much detail on things that aren't really relevant for a user of this functionality. This whole new block would likely be better as a simple summary, leaving the detailed explanation in the document comments which are pushed to the package documenation. |
||
|
|
||
| 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 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. | ||
|
|
||
| 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 | ||
| `flutter test --enable-vmservice test_vm/first_flags_capture_test.dart`. | ||
|
Comment on lines
+426
to
+428
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This definitely doesn't belong in the README IMO. Remember this README becomes the front page documentation of the package. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,8 @@ dependencies: | |
| path: .. | ||
|
|
||
| dev_dependencies: | ||
| test: ^1.25.0 | ||
| http: ^1.0.0 | ||
| lints: '>=5.0.0' | ||
|
|
||
| executables: | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What type of errors would this catch that wouldn't be caught with other tests? I feel like unit testing the example adds more maintenance without much benefit... |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ import 'evaluation_context.dart'; | |
| import 'exposure_logger.dart'; | ||
| import 'flags_client.dart'; | ||
| import 'flags_error.dart'; | ||
| import 'flags_client_event.dart'; | ||
| import 'flags_repository.dart'; | ||
|
|
||
| class DefaultDatadogFlagsClient implements DatadogFlagsClient { | ||
|
|
@@ -29,6 +30,11 @@ class DefaultDatadogFlagsClient implements DatadogFlagsClient { | |
| _exposureLogger = exposureLogger, | ||
| _evaluationAggregator = evaluationAggregator; | ||
|
|
||
| @override | ||
| void Function() onFirstFlags( | ||
| void Function(FlagsClientEvent event) listener) => | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might be worth creating a |
||
| _repository.onFirstFlags(listener); | ||
|
|
||
| @override | ||
| Future<void> initialize(FlagsEvaluationContext context) async { | ||
| await _repository.initialize(context); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,33 @@ 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. | ||
| /// 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. 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 | ||
| /// 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. | ||
|
Comment on lines
+19
to
+41
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel like this whole comment could be simplified, or at least more understandable. Things like "not one event per flag or a (etc..)" feel like they could be omitted, explaining only what the callback does, not what it doesn't do, unless the user has some reasonable expectation that it would / should do that. The wording of "do not consume the notification" is odd to me. I'm guessing that means we don't get the callback in the case of an error? The second paragraph also feels overly verbose. Just knowing it is safe to throw exceptions / errors in the callback, and that it is safe for the callback to start other async work should be enough, if that's indeed what it's saying? Anyway, please take another read over this and see if we can't simplify it. |
||
| void Function() onFirstFlags( | ||
| void Function(FlagsClientEvent event) listener, | ||
| ); | ||
|
|
||
| /// Fetches assignments for [context] and makes them available to evaluations. | ||
| /// | ||
| /// The first call completes when initialization finishes or the configured | ||
|
|
@@ -26,7 +54,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<void> initialize( | ||
| FlagsEvaluationContext context, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| // 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. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What does this mean? |
||
| 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; | ||
|
|
||
| /// 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<String>? flagsChanged; | ||
|
|
||
| /// SDK-internal event construction; applications receive events from | ||
| /// [DatadogFlagsClient.onFirstFlags] rather than constructing them. | ||
| /// | ||
| /// Copies [flagsChanged] into an unmodifiable list when supplied. Later | ||
| /// changes to the source list cannot change this value. | ||
| @internal | ||
| FlagsClientEvent({required this.type, List<String>? flagsChanged}) | ||
| : flagsChanged = flagsChanged == null | ||
| ? null | ||
| : List<String>.unmodifiable(flagsChanged); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changelog items are generated from the release process.