Skip to content
10 changes: 10 additions & 0 deletions packages/datadog_flags/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

### 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.
Comment on lines +3 to +11

Copy link
Copy Markdown
Member

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.


## 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.
Expand Down
63 changes: 63 additions & 0 deletions packages/datadog_flags/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

13 changes: 10 additions & 3 deletions packages/datadog_flags/example/bin/typed_evaluation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ Future<void> main(List<String> 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(
Expand All @@ -56,14 +60,17 @@ Future<void> main(List<String> arguments) async {
stderr.writeln(error.message);
}

final details = _evaluate(flags, flagKey, flagType);
_printDetails(_evaluate(flags, flagKey, flagType));
unregister();
await datadogFlags.disable();
}

void _printDetails(FlagDetails<Object?> 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() {
Expand Down
2 changes: 2 additions & 0 deletions packages/datadog_flags/example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ dependencies:
path: ..

dev_dependencies:
test: ^1.25.0
http: ^1.0.0
lints: '>=5.0.0'

executables:
Expand Down
73 changes: 73 additions & 0 deletions packages/datadog_flags/example/test/typed_evaluation_test.dart

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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);
3 changes: 3 additions & 0 deletions packages/datadog_flags/lib/datadog_flags.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
6 changes: 6 additions & 0 deletions packages/datadog_flags/lib/src/default_flags_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,6 +30,11 @@ class DefaultDatadogFlagsClient implements DatadogFlagsClient {
_exposureLogger = exposureLogger,
_evaluationAggregator = evaluationAggregator;

@override
void Function() onFirstFlags(
void Function(FlagsClientEvent event) listener) =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth creating a typedef for this Function type.

_repository.onFirstFlags(listener);

@override
Future<void> initialize(FlagsEvaluationContext context) async {
await _repository.initialize(context);
Expand Down
30 changes: 29 additions & 1 deletion packages/datadog_flags/lib/src/flags_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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
Expand All @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions packages/datadog_flags/lib/src/flags_client_event.dart
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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);
}
Loading
Loading