From 129670674d812de8f9e73e6d0f99fd1c0c48a912 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 19:02:57 -0400 Subject: [PATCH 01/16] Add native Datadog Flags Flutter client --- .../android/app/src/main/AndroidManifest.xml | 3 +- .../simple_example/android/gradle.properties | 4 + .../integration_test/flags_dogfood_test.dart | 48 ++ examples/simple_example/ios/Podfile.lock | 61 +- .../ios/Runner.xcodeproj/project.pbxproj | 18 - examples/simple_example/ios/Runner/Info.plist | 5 + examples/simple_example/lib/app.dart | 84 +-- .../lib/flags/flags_demo_runtime.dart | 79 +++ .../lib/flags/local_flags_collector.dart | 7 + .../lib/flags/local_flags_collector_io.dart | 71 +++ .../lib/flags/local_flags_collector_stub.dart | 71 +++ .../lib/flags/local_flags_payloads.dart | 70 +++ examples/simple_example/lib/main.dart | 50 +- examples/simple_example/lib/main_screen.dart | 1 + .../lib/screens/flags_screen.dart | 188 ++++++ examples/simple_example/pubspec.lock | 120 +++- examples/simple_example/pubspec.yaml | 7 + .../test/flags_dogfood_web_test.dart | 59 ++ packages/datadog_flags/.gitignore | 31 + packages/datadog_flags/.metadata | 10 + packages/datadog_flags/CHANGELOG.md | 6 + packages/datadog_flags/LICENSE | 1 + packages/datadog_flags/README.md | 76 +++ packages/datadog_flags/analysis_options.yaml | 4 + packages/datadog_flags/lib/datadog_flags.dart | 19 + .../datadog_flags/lib/src/assignment.dart | 119 ++++ .../lib/src/datadog_context.dart | 114 ++++ .../datadog_flags/lib/src/datadog_flags.dart | 172 +++++ .../lib/src/evaluation_aggregator.dart | 208 +++++++ .../lib/src/exposure_logger.dart | 83 +++ .../lib/src/flag_assignments_fetcher.dart | 127 ++++ .../datadog_flags/lib/src/flags_client.dart | 264 ++++++++ .../lib/src/flags_configuration.dart | 69 +++ .../datadog_flags/lib/src/flags_context.dart | 34 + .../datadog_flags/lib/src/flags_details.dart | 22 + .../datadog_flags/lib/src/flags_error.dart | 47 ++ .../lib/src/flags_repository.dart | 56 ++ .../datadog_flags/lib/src/flags_store.dart | 105 ++++ .../datadog_flags/lib/src/json_value.dart | 43 ++ .../lib/src/rum_flag_evaluation_reporter.dart | 28 + packages/datadog_flags/pubspec.yaml | 26 + .../test/datadog_flags_test.dart | 585 ++++++++++++++++++ .../precomputed/cases/all-types-success.json | 133 ++++ .../cases/defaults-and-emission-gates.json | 94 +++ .../test/precomputed_fixture_test.dart | 197 ++++++ .../lib/datadog_flutter_plugin.dart | 2 + .../test/datadog_sdk_test.dart | 12 + 47 files changed, 3537 insertions(+), 96 deletions(-) create mode 100644 examples/simple_example/integration_test/flags_dogfood_test.dart create mode 100644 examples/simple_example/lib/flags/flags_demo_runtime.dart create mode 100644 examples/simple_example/lib/flags/local_flags_collector.dart create mode 100644 examples/simple_example/lib/flags/local_flags_collector_io.dart create mode 100644 examples/simple_example/lib/flags/local_flags_collector_stub.dart create mode 100644 examples/simple_example/lib/flags/local_flags_payloads.dart create mode 100644 examples/simple_example/lib/screens/flags_screen.dart create mode 100644 examples/simple_example/test/flags_dogfood_web_test.dart create mode 100644 packages/datadog_flags/.gitignore create mode 100644 packages/datadog_flags/.metadata create mode 100644 packages/datadog_flags/CHANGELOG.md create mode 100644 packages/datadog_flags/LICENSE create mode 100644 packages/datadog_flags/README.md create mode 100644 packages/datadog_flags/analysis_options.yaml create mode 100644 packages/datadog_flags/lib/datadog_flags.dart create mode 100644 packages/datadog_flags/lib/src/assignment.dart create mode 100644 packages/datadog_flags/lib/src/datadog_context.dart create mode 100644 packages/datadog_flags/lib/src/datadog_flags.dart create mode 100644 packages/datadog_flags/lib/src/evaluation_aggregator.dart create mode 100644 packages/datadog_flags/lib/src/exposure_logger.dart create mode 100644 packages/datadog_flags/lib/src/flag_assignments_fetcher.dart create mode 100644 packages/datadog_flags/lib/src/flags_client.dart create mode 100644 packages/datadog_flags/lib/src/flags_configuration.dart create mode 100644 packages/datadog_flags/lib/src/flags_context.dart create mode 100644 packages/datadog_flags/lib/src/flags_details.dart create mode 100644 packages/datadog_flags/lib/src/flags_error.dart create mode 100644 packages/datadog_flags/lib/src/flags_repository.dart create mode 100644 packages/datadog_flags/lib/src/flags_store.dart create mode 100644 packages/datadog_flags/lib/src/json_value.dart create mode 100644 packages/datadog_flags/lib/src/rum_flag_evaluation_reporter.dart create mode 100644 packages/datadog_flags/pubspec.yaml create mode 100644 packages/datadog_flags/test/datadog_flags_test.dart create mode 100644 packages/datadog_flags/test/fixtures/precomputed/cases/all-types-success.json create mode 100644 packages/datadog_flags/test/fixtures/precomputed/cases/defaults-and-emission-gates.json create mode 100644 packages/datadog_flags/test/precomputed_fixture_test.dart diff --git a/examples/simple_example/android/app/src/main/AndroidManifest.xml b/examples/simple_example/android/app/src/main/AndroidManifest.xml index 291671a68..de1653362 100644 --- a/examples/simple_example/android/app/src/main/AndroidManifest.xml +++ b/examples/simple_example/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,8 @@ + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> 3) - Flutter - webview_flutter_wkwebview - - DatadogCore (3.9.1): - - DatadogInternal (= 3.9.1) - - DatadogCrashReporting (3.9.1): - - DatadogInternal (= 3.9.1) + - DatadogCore (3.11.1): + - DatadogInternal (= 3.11.1) + - DatadogCrashReporting (3.11.1): + - DatadogInternal (= 3.11.1) - KSCrash/Filters (= 2.5.0) - KSCrash/Recording (= 2.5.0) - - DatadogInternal (3.9.1) - - DatadogLogs (3.9.1): - - DatadogInternal (= 3.9.1) - - DatadogRUM (3.9.1): - - DatadogInternal (= 3.9.1) - - DatadogWebViewTracking (3.9.1): - - DatadogInternal (= 3.9.1) + - DatadogInternal (3.11.1) + - DatadogLogs (3.11.1): + - DatadogInternal (= 3.11.1) + - DatadogRUM (3.11.1): + - DatadogInternal (= 3.11.1) + - DatadogWebViewTracking (3.11.1): + - DatadogInternal (= 3.11.1) - DictionaryCoder (1.2.0) - Flutter (1.0.0) + - integration_test (0.0.1): + - Flutter - KSCrash/Core (2.5.0) - KSCrash/Filters (2.5.0): - KSCrash/Recording @@ -49,6 +51,9 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS - webview_flutter_wkwebview (0.0.1): - Flutter - FlutterMacOS @@ -59,8 +64,10 @@ DEPENDENCIES: - datadog_session_replay (from `.symlinks/plugins/datadog_session_replay/ios`) - datadog_webview_tracking (from `.symlinks/plugins/datadog_webview_tracking/ios`) - Flutter (from `Flutter`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) - objective_c (from `.symlinks/plugins/objective_c/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) SPEC REPOS: @@ -85,30 +92,36 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/datadog_webview_tracking/ios" Flutter: :path: Flutter + integration_test: + :path: ".symlinks/plugins/integration_test/ios" objective_c: :path: ".symlinks/plugins/objective_c/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" webview_flutter_wkwebview: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - connectivity_plus: 18382e7311ba19efcaee94442b23b32507b20695 - datadog_flutter_plugin: 64adc7ef089a1328c78fd4fab24e49afa09d95a5 - datadog_session_replay: 8e4f4a520f20feb2916c623c011a53ba63f8d77f - datadog_webview_tracking: 58ffca1b06ba88e0bd6dc0d0d1dada0e60e6d3d5 - DatadogCore: e27bba3c2f490ae41ac17d41aecadee85475fe6d - DatadogCrashReporting: 23da244e20c2af0d159097dd10cc9ce8ef954bcc - DatadogInternal: e9ff671207d5a14354364101d32d60897c48d13a - DatadogLogs: 664dbb3598f95817429db0025575289c1f63efbf - DatadogRUM: dd30158552e5b48898cc2e6084eda1754c03ad6e - DatadogWebViewTracking: b8c35c23ac90ee4ba2b3879577451e27ec86ebcd + connectivity_plus: 2256d3e20624a7749ed21653aafe291a46446fee + datadog_flutter_plugin: ece4fa34fdd699f4c36547df450da4417f904580 + datadog_session_replay: 29f1e456d30632bb22ad1dd0eceb15811d2fbc6c + datadog_webview_tracking: d92b870c96d7e646ac838f9c4a79d5fd70468139 + DatadogCore: ac5ffe0cb500e4c826fa08e63767ab3b4434c380 + DatadogCrashReporting: 699c20e4af5798a39b14c81e26c0e92293d86a36 + DatadogInternal: 4ee2f5bab0371f161c8010637fff5331f49542d7 + DatadogLogs: 9f7f10c34ea9cc4c6699f98ab2420da843f78f4b + DatadogRUM: 14a5f96c465e85d56ec501508d728b7aea9c2980 + DatadogWebViewTracking: 99631d80d34cb30cf6be9b620dc6659f7f5bd15f DictionaryCoder: f7115fcd074c8301e91f2eb862da1ea7d0385e61 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e KSCrash: 80e1e24eaefbe5134934ae11ca8d7746586bc2ed - objective_c: 77e887b5ba1827970907e10e832eec1683f3431d - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188 + objective_c: 89e720c30d716b036faf9c9684022048eee1eee2 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 PODFILE CHECKSUM: cc1f88378b4bfcf93a6ce00d2c587857c6008d3b diff --git a/examples/simple_example/ios/Runner.xcodeproj/project.pbxproj b/examples/simple_example/ios/Runner.xcodeproj/project.pbxproj index ab6442cc7..9307961f7 100644 --- a/examples/simple_example/ios/Runner.xcodeproj/project.pbxproj +++ b/examples/simple_example/ios/Runner.xcodeproj/project.pbxproj @@ -140,7 +140,6 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 50E793F5A550121A16F6AF72 /* [CP] Embed Pods Frameworks */, - C5A6A31AA8D394C2AA220E42 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -269,23 +268,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - C5A6A31AA8D394C2AA220E42 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/examples/simple_example/ios/Runner/Info.plist b/examples/simple_example/ios/Runner/Info.plist index f3b7db71f..4585e80ef 100644 --- a/examples/simple_example/ios/Runner/Info.plist +++ b/examples/simple_example/ios/Runner/Info.plist @@ -22,6 +22,11 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + NSAppTransportSecurity + + NSAllowsLocalNetworking + + LSRequiresIPhoneOS UILaunchStoryboardName diff --git a/examples/simple_example/lib/app.dart b/examples/simple_example/lib/app.dart index f7a43c4b7..d455420f3 100644 --- a/examples/simple_example/lib/app.dart +++ b/examples/simple_example/lib/app.dart @@ -9,14 +9,21 @@ import 'package:go_router/go_router.dart'; import 'package:graphql_flutter/graphql_flutter.dart'; import 'main_screen.dart'; +import 'flags/flags_demo_runtime.dart'; +import 'screens/flags_screen.dart'; import 'screens/crash_screen.dart'; import 'screens/graph_ql_screen.dart'; import 'screens/network_screen.dart'; class MyApp extends StatefulWidget { final GraphQLClient graphQLClient; + final FlagsDemoRuntime flagsRuntime; - const MyApp({super.key, required this.graphQLClient}); + const MyApp({ + super.key, + required this.graphQLClient, + required this.flagsRuntime, + }); @override State createState() => _MyAppState(); @@ -25,41 +32,46 @@ class MyApp extends StatefulWidget { class _MyAppState extends State { var captureKey = GlobalKey(); - final router = GoRouter( - observers: [DatadogNavigationObserver(datadogSdk: DatadogSdk.instance)], - routes: [ - GoRoute( - path: '/', - builder: (context, state) { - return const MainScreen(); - }, - ), - GoRoute( - path: '/home', - builder: (context, state) { - return const MyHomePage(title: 'Home'); - }, - ), - GoRoute( - path: '/network', - builder: (context, state) { - return const NetworkScreen(); - }, - ), - GoRoute( - path: '/graphql', - builder: (context, state) { - return const GraphQlScreen(); - }, - ), - GoRoute( - path: '/crash', - builder: (context, state) { - return const CrashTestScreen(); - }, - ), - ], - ); + late final router = GoRouter(observers: [ + DatadogNavigationObserver(datadogSdk: DatadogSdk.instance) + ], routes: [ + GoRoute( + path: '/', + builder: (context, state) { + return const MainScreen(); + }, + ), + GoRoute( + path: '/home', + builder: (context, state) { + return const MyHomePage(title: 'Home'); + }, + ), + GoRoute( + path: '/network', + builder: (context, state) { + return const NetworkScreen(); + }, + ), + GoRoute( + path: '/graphql', + builder: (context, state) { + return const GraphQlScreen(); + }, + ), + GoRoute( + path: '/crash', + builder: (context, state) { + return const CrashTestScreen(); + }, + ), + GoRoute( + path: '/flags', + builder: (context, state) { + return FlagsScreen(runtime: widget.flagsRuntime); + }, + ), + ]); @override Widget build(BuildContext context) { diff --git a/examples/simple_example/lib/flags/flags_demo_runtime.dart b/examples/simple_example/lib/flags/flags_demo_runtime.dart new file mode 100644 index 000000000..58c365e04 --- /dev/null +++ b/examples/simple_example/lib/flags/flags_demo_runtime.dart @@ -0,0 +1,79 @@ +// 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:datadog_flutter_plugin/datadog_flutter_plugin.dart'; + +import 'local_flags_collector.dart'; + +const _externalFlagsEndpoint = String.fromEnvironment('FLAGS_ENDPOINT'); +const _externalExposureEndpoint = + String.fromEnvironment('FLAGS_EXPOSURE_ENDPOINT'); +const _externalEvaluationEndpoint = + String.fromEnvironment('FLAGS_EVALUATION_ENDPOINT'); + +class FlagsDemoRuntime { + final String mode; + final LocalFlagsCollector? collector; + final DatadogFlagsConfiguration configuration; + + const FlagsDemoRuntime._({ + required this.mode, + required this.collector, + required this.configuration, + }); + + Future stop() async { + await collector?.stop(); + } + + static Future create() async { + const mode = String.fromEnvironment('FLAGS_MODE', defaultValue: 'local'); + final externalFlagsEndpoint = _uriFromEnvironment(_externalFlagsEndpoint); + final externalExposureEndpoint = + _uriFromEnvironment(_externalExposureEndpoint); + final externalEvaluationEndpoint = + _uriFromEnvironment(_externalEvaluationEndpoint); + + LocalFlagsCollector? collector; + if (mode == 'local' && externalFlagsEndpoint == null) { + collector = await LocalFlagsCollector.start(); + } + + return FlagsDemoRuntime._( + mode: mode, + collector: collector, + configuration: DatadogFlagsConfiguration( + customFlagsEndpoint: + externalFlagsEndpoint ?? collector?.precomputeEndpoint, + customExposureEndpoint: + externalExposureEndpoint ?? collector?.exposureEndpoint, + customEvaluationEndpoint: + externalEvaluationEndpoint ?? collector?.evaluationEndpoint, + httpClient: collector?.httpClient, + store: mode == 'local' ? InMemoryDatadogFlagsStore() : null, + datadogContext: mode == 'local' + ? const DatadogFlagsContext( + clientToken: 'local-client-token', + env: 'local', + site: DatadogSite.us1, + service: 'simple-example', + version: '1.0.0', + applicationId: 'local-application-id', + sdkVersion: '3.3.0', + ) + : null, + evaluationFlushInterval: const Duration(seconds: 1), + ), + ); + } +} + +Uri? _uriFromEnvironment(String value) { + if (value.isEmpty) { + return null; + } + return Uri.parse(value); +} diff --git a/examples/simple_example/lib/flags/local_flags_collector.dart b/examples/simple_example/lib/flags/local_flags_collector.dart new file mode 100644 index 000000000..1241287c7 --- /dev/null +++ b/examples/simple_example/lib/flags/local_flags_collector.dart @@ -0,0 +1,7 @@ +// 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. + +export 'local_flags_collector_stub.dart' + if (dart.library.io) 'local_flags_collector_io.dart'; diff --git a/examples/simple_example/lib/flags/local_flags_collector_io.dart b/examples/simple_example/lib/flags/local_flags_collector_io.dart new file mode 100644 index 000000000..77c3c68ba --- /dev/null +++ b/examples/simple_example/lib/flags/local_flags_collector_io.dart @@ -0,0 +1,71 @@ +// 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 'local_flags_payloads.dart'; + +class LocalFlagsCollector { + final HttpServer _server; + int exposureCount = 0; + int evaluationRequestCount = 0; + int evaluationEventCount = 0; + + LocalFlagsCollector._(this._server) { + _server.listen(_handleRequest); + } + + Uri get _baseUri => Uri( + scheme: 'http', + host: InternetAddress.loopbackIPv4.address, + port: _server.port, + ); + + Uri get precomputeEndpoint => + _baseUri.replace(path: '/precompute-assignments'); + Uri get exposureEndpoint => _baseUri.replace(path: '/api/v2/exposures'); + Uri get evaluationEndpoint => + _baseUri.replace(path: '/api/v2/flagevaluation'); + http.Client? get httpClient => null; + + static Future start() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + return LocalFlagsCollector._(server); + } + + Future stop() { + return _server.close(force: true); + } + + Future _handleRequest(HttpRequest request) async { + final path = request.uri.path; + if (path == '/precompute-assignments') { + await utf8.decoder.bind(request).join(); + _writeJson(request.response, localPrecomputeResponse()); + } else if (path == '/api/v2/exposures') { + final body = await utf8.decoder.bind(request).join(); + exposureCount += countExposureBody(body); + _writeJson(request.response, {'ok': true}); + } else if (path == '/api/v2/flagevaluation') { + final body = await utf8.decoder.bind(request).join(); + evaluationRequestCount += 1; + evaluationEventCount += countEvaluationEvents(body); + _writeJson(request.response, {'ok': true}); + } else { + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + } + } + + void _writeJson(HttpResponse response, Object body) { + response.statusCode = HttpStatus.ok; + response.headers.contentType = ContentType.json; + response.write(jsonEncode(body)); + response.close(); + } +} diff --git a/examples/simple_example/lib/flags/local_flags_collector_stub.dart b/examples/simple_example/lib/flags/local_flags_collector_stub.dart new file mode 100644 index 000000000..d5f5699cd --- /dev/null +++ b/examples/simple_example/lib/flags/local_flags_collector_stub.dart @@ -0,0 +1,71 @@ +// 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:http/http.dart' as http; + +import 'local_flags_payloads.dart'; + +class LocalFlagsCollector { + final http.Client httpClient; + int exposureCount = 0; + int evaluationRequestCount = 0; + int evaluationEventCount = 0; + + LocalFlagsCollector._() : httpClient = _LocalFlagsClient(); + + Uri get _baseUri => Uri.parse('https://local.datadog.flags'); + + Uri get precomputeEndpoint => + _baseUri.replace(path: '/precompute-assignments'); + Uri get exposureEndpoint => _baseUri.replace(path: '/api/v2/exposures'); + Uri get evaluationEndpoint => + _baseUri.replace(path: '/api/v2/flagevaluation'); + + static Future start() async { + final collector = LocalFlagsCollector._(); + (collector.httpClient as _LocalFlagsClient)._collector = collector; + return collector; + } + + Future stop() async { + httpClient.close(); + } +} + +class _LocalFlagsClient extends http.BaseClient { + LocalFlagsCollector? _collector; + + @override + Future send(http.BaseRequest request) async { + final body = utf8.decode(await request.finalize().toBytes()); + final path = request.url.path; + if (path == '/precompute-assignments') { + return _jsonResponse(localPrecomputeResponse()); + } + if (path == '/api/v2/exposures') { + _collector?.exposureCount += countExposureBody(body); + return _jsonResponse({'ok': true}); + } + if (path == '/api/v2/flagevaluation') { + _collector?.evaluationRequestCount += 1; + _collector?.evaluationEventCount += countEvaluationEvents(body); + return _jsonResponse({'ok': true}); + } + return _jsonResponse({'error': 'not found'}, statusCode: 404); + } + + http.StreamedResponse _jsonResponse( + Object body, { + int statusCode = 200, + }) { + return http.StreamedResponse( + Stream.value(utf8.encode(jsonEncode(body))), + statusCode, + headers: {'content-type': 'application/json'}, + ); + } +} diff --git a/examples/simple_example/lib/flags/local_flags_payloads.dart b/examples/simple_example/lib/flags/local_flags_payloads.dart new file mode 100644 index 000000000..7de84fc20 --- /dev/null +++ b/examples/simple_example/lib/flags/local_flags_payloads.dart @@ -0,0 +1,70 @@ +// 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'; + +Map localPrecomputeResponse() { + return { + 'data': { + 'attributes': { + 'flags': { + 'flutter.demo.enabled': { + 'allocationKey': 'allocation-mobile-demo', + 'variationKey': 'enabled', + 'variationType': 'boolean', + 'variationValue': true, + 'reason': 'TARGETING_MATCH', + 'doLog': true, + }, + 'flutter.demo.title': { + 'allocationKey': 'allocation-mobile-demo', + 'variationKey': 'copy-a', + 'variationType': 'string', + 'variationValue': 'Datadog Flags', + 'reason': 'TARGETING_MATCH', + 'doLog': true, + }, + 'flutter.demo.limit': { + 'allocationKey': 'allocation-mobile-demo', + 'variationKey': 'limit-five', + 'variationType': 'integer', + 'variationValue': 5, + 'reason': 'TARGETING_MATCH', + 'doLog': true, + }, + 'flutter.demo.ratio': { + 'allocationKey': 'allocation-mobile-demo', + 'variationKey': 'half', + 'variationType': 'float', + 'variationValue': 0.5, + 'reason': 'TARGETING_MATCH', + 'doLog': true, + }, + 'flutter.demo.config': { + 'allocationKey': 'allocation-mobile-demo', + 'variationKey': 'object-a', + 'variationType': 'object', + 'variationValue': { + 'showBanner': true, + 'colors': ['blue', 'green'], + }, + 'reason': 'TARGETING_MATCH', + 'doLog': true, + }, + }, + }, + }, + }; +} + +int countExposureBody(String body) { + return body.split('\n').where((line) => line.trim().isNotEmpty).length; +} + +int countEvaluationEvents(String body) { + final decoded = jsonDecode(body) as Map; + final evaluations = decoded['flagEvaluations'] as List; + return evaluations.length; +} diff --git a/examples/simple_example/lib/main.dart b/examples/simple_example/lib/main.dart index 3b85f9b67..f0f7faabf 100644 --- a/examples/simple_example/lib/main.dart +++ b/examples/simple_example/lib/main.dart @@ -3,6 +3,7 @@ // Copyright 2023-Present Datadog, Inc. import 'package:datadog_flutter_plugin/datadog_flutter_plugin.dart'; +import 'package:datadog_flags/datadog_flags.dart'; import 'package:datadog_gql_link/datadog_gql_link.dart'; import 'package:datadog_session_replay/datadog_session_replay.dart'; import 'package:datadog_tracking_http_client/datadog_tracking_http_client.dart'; @@ -12,11 +13,13 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:graphql_flutter/graphql_flutter.dart'; import 'app.dart'; +import 'flags/flags_demo_runtime.dart'; import 'url_strategy_stub.dart' if (dart.library.html) 'url_strategy_web.dart'; const graphQlUrl = 'http://localhost:3000/graphql'; +const flagsMode = String.fromEnvironment('FLAGS_MODE', defaultValue: 'local'); -void main() async { +Future main() async { await dotenv.load(); WidgetsFlutterBinding.ensureInitialized(); @@ -25,13 +28,19 @@ void main() async { DatadogSdk.instance.sdkVerbosity = CoreLoggerLevel.debug; final datadogConfig = DatadogConfiguration( - clientToken: dotenv.get('DD_CLIENT_TOKEN', fallback: ''), - env: dotenv.get('DD_ENV', fallback: ''), + clientToken: _dotenvValue( + 'DD_CLIENT_TOKEN', + localFallback: 'local-client-token', + ), + env: _dotenvValue('DD_ENV', localFallback: 'local'), site: DatadogSite.us1, loggingConfiguration: DatadogLoggingConfiguration(), firstPartyHosts: ['localhost'], rumConfiguration: DatadogRumConfiguration( - applicationId: dotenv.get('DD_APPLICATION_ID', fallback: ''), + applicationId: _dotenvValue( + 'DD_APPLICATION_ID', + localFallback: 'local-application-id', + ), traceSampleRate: 100.0, trackResourceHeaders: ResourceHeadersExtractor( captureHeaders: [ @@ -65,6 +74,14 @@ void main() async { ); } +String _dotenvValue(String name, {required String localFallback}) { + final value = dotenv.maybeGet(name); + if (value != null && value.isNotEmpty) { + return value; + } + return flagsMode == 'local' ? localFallback : ''; +} + Future runUsingAlternativeInit(DatadogConfiguration datadogConfig) async { final originalOnError = FlutterError.onError; FlutterError.onError = (details) { @@ -84,6 +101,8 @@ Future runUsingAlternativeInit(DatadogConfiguration datadogConfig) async { }; await DatadogSdk.instance.initialize(datadogConfig, TrackingConsent.granted); + final flagsRuntime = await FlagsDemoRuntime.create(); + await DatadogFlags.enable(configuration: flagsRuntime.configuration); final link = Link.from([ DatadogGqlLink(DatadogSdk.instance, Uri.parse(graphQlUrl)), HttpLink(graphQlUrl), @@ -92,19 +111,26 @@ Future runUsingAlternativeInit(DatadogConfiguration datadogConfig) async { final graphQlClient = GraphQLClient(link: link, cache: GraphQLCache()); runApp(MyApp( graphQLClient: graphQlClient, + flagsRuntime: flagsRuntime, )); } Future runUsingRunApp(DatadogConfiguration datadogConfig) async { await DatadogSdk.runApp(datadogConfig, TrackingConsent.granted, () { - final link = Link.from([ - DatadogGqlLink(DatadogSdk.instance, Uri.parse(graphQlUrl)), - HttpLink(graphQlUrl), - ]); - final graphQlClient = GraphQLClient(link: link, cache: GraphQLCache()); + // This path is not used by default, but keep flags configured for parity + // if the example is switched back to DatadogSdk.runApp. + FlagsDemoRuntime.create().then((flagsRuntime) async { + await DatadogFlags.enable(configuration: flagsRuntime.configuration); + final link = Link.from([ + DatadogGqlLink(DatadogSdk.instance, Uri.parse(graphQlUrl)), + HttpLink(graphQlUrl), + ]); + final graphQlClient = GraphQLClient(link: link, cache: GraphQLCache()); - runApp(MyApp( - graphQLClient: graphQlClient, - )); + runApp(MyApp( + graphQLClient: graphQlClient, + flagsRuntime: flagsRuntime, + )); + }); }); } diff --git a/examples/simple_example/lib/main_screen.dart b/examples/simple_example/lib/main_screen.dart index 624aff382..b9b216a2a 100644 --- a/examples/simple_example/lib/main_screen.dart +++ b/examples/simple_example/lib/main_screen.dart @@ -43,6 +43,7 @@ class _MainScreenState extends State { _paddedNavButton('Home', '/home'), _paddedNavButton('Network', '/network'), _paddedNavButton('GraphQl', '/graphql'), + _paddedNavButton('Flags', '/flags'), _paddedNavButton('Crash', '/crash'), ], ), diff --git a/examples/simple_example/lib/screens/flags_screen.dart b/examples/simple_example/lib/screens/flags_screen.dart new file mode 100644 index 000000000..2ea620838 --- /dev/null +++ b/examples/simple_example/lib/screens/flags_screen.dart @@ -0,0 +1,188 @@ +// 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:flutter/material.dart'; + +import '../flags/flags_demo_runtime.dart'; + +class FlagsScreen extends StatefulWidget { + final FlagsDemoRuntime runtime; + + const FlagsScreen({super.key, required this.runtime}); + + @override + State createState() => _FlagsScreenState(); +} + +class _FlagsScreenState extends State { + static const _targetingKey = String.fromEnvironment('FLAGS_TARGETING_KEY', + defaultValue: 'flutter-user'); + + late final DatadogFlagsClient _client; + String _status = 'idle'; + FlagDetails? _enabled; + FlagDetails? _title; + FlagDetails? _limit; + FlagDetails? _ratio; + FlagDetails? _config; + + @override + void initState() { + super.initState(); + _client = DatadogFlagsClient.shared(); + _refreshFlags(); + } + + Future _refreshFlags() async { + setState(() { + _status = 'fetching'; + }); + await _client.setEvaluationContext(const DatadogFlagsEvaluationContext( + targetingKey: _targetingKey, + attributes: { + 'plan': 'dogfood', + 'platform': 'flutter', + }, + )); + _evaluate(); + } + + void _evaluate() { + setState(() { + _enabled = _client.getBooleanDetails( + key: 'flutter.demo.enabled', + defaultValue: false, + ); + _title = _client.getStringDetails( + key: 'flutter.demo.title', + defaultValue: 'Fallback title', + ); + _limit = _client.getIntegerDetails( + key: 'flutter.demo.limit', + defaultValue: 0, + ); + _ratio = _client.getDoubleDetails( + key: 'flutter.demo.ratio', + defaultValue: 0, + ); + _config = _client.getObjectDetails( + key: 'flutter.demo.config', + defaultValue: const {}, + ); + _status = 'evaluated'; + }); + } + + Future _flush() async { + await _client.flush(); + setState(() { + _status = 'flushed'; + }); + } + + @override + Widget build(BuildContext context) { + final collector = widget.runtime.collector; + return Scaffold( + appBar: AppBar(title: const Text('Flags')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _Row(label: 'Mode', value: widget.runtime.mode), + _Row(label: 'Status', value: _status), + const _Row(label: 'Targeting key', value: _targetingKey), + if (collector != null) ...[ + _Row( + label: 'Exposures', + value: '${collector.exposureCount}', + valueKey: const Key('flags-exposure-count'), + ), + _Row( + label: 'Evaluation requests', + value: '${collector.evaluationRequestCount}', + valueKey: const Key('flags-evaluation-request-count'), + ), + _Row( + label: 'Evaluation events', + value: '${collector.evaluationEventCount}', + valueKey: const Key('flags-evaluation-event-count'), + ), + ], + const SizedBox(height: 16), + _DetailsRow(label: 'Boolean', details: _enabled), + _DetailsRow(label: 'String', details: _title), + _DetailsRow(label: 'Integer', details: _limit), + _DetailsRow(label: 'Double', details: _ratio), + _DetailsRow(label: 'Object', details: _config), + const SizedBox(height: 16), + Wrap( + spacing: 12, + children: [ + ElevatedButton( + onPressed: _refreshFlags, + child: const Text('Set context'), + ), + ElevatedButton( + onPressed: _evaluate, + child: const Text('Evaluate'), + ), + ElevatedButton( + onPressed: _flush, + child: const Text('Flush'), + ), + ], + ), + ], + ), + ); + } +} + +class _DetailsRow extends StatelessWidget { + final String label; + final FlagDetails? details; + + const _DetailsRow({required this.label, required this.details}); + + @override + Widget build(BuildContext context) { + final value = details; + return _Row( + label: label, + value: value == null + ? '-' + : '${value.value} | variant=${value.variant ?? '-'} | error=${value.error?.name ?? '-'}', + ); + } +} + +class _Row extends StatelessWidget { + final String label; + final String value; + final Key? valueKey; + + const _Row({required this.label, required this.value, this.valueKey}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 150, + child: Text( + label, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + Expanded(child: Text(value, key: valueKey)), + ], + ), + ); + } +} diff --git a/examples/simple_example/pubspec.lock b/examples/simple_example/pubspec.lock index 0ed8739d7..d4282d5ad 100644 --- a/examples/simple_example/pubspec.lock +++ b/examples/simple_example/pubspec.lock @@ -81,6 +81,13 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + datadog_flags: + dependency: "direct main" + description: + path: "../../packages/datadog_flags" + relative: true + source: path + version: "0.0.1" datadog_flutter_plugin: dependency: "direct main" description: @@ -140,6 +147,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" fixnum: dependency: transitive description: @@ -161,6 +176,11 @@ packages: url: "https://pub.dev" source: hosted version: "5.2.1" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_hooks: dependency: transitive description: @@ -187,6 +207,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" go_router: dependency: "direct main" description: @@ -276,7 +301,7 @@ packages: source: hosted version: "2.2.3" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f @@ -291,6 +316,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" jni: dependency: transitive description: @@ -483,6 +513,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" pub_semver: dependency: transitive description: @@ -499,6 +537,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "67724122431ea01d9318ad1ce08b6ca79f77a38366826f01a45a8ac61b693a01" + url: "https://pub.dev" + source: hosted + version: "2.4.24" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -544,6 +638,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -556,10 +658,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" transparent_image: dependency: "direct main" description: @@ -624,6 +726,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" webview_flutter: dependency: transitive description: @@ -681,5 +791,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.0-0 <4.0.0" - flutter: ">=3.32.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/examples/simple_example/pubspec.yaml b/examples/simple_example/pubspec.yaml index efc502c96..07fdd1bec 100644 --- a/examples/simple_example/pubspec.yaml +++ b/examples/simple_example/pubspec.yaml @@ -23,8 +23,11 @@ dependencies: path: ../../packages/datadog_gql_link datadog_session_replay: path: ../../packages/datadog_session_replay + datadog_flags: + path: ../../packages/datadog_flags go_router: ^14.7.0 + http: ^1.0.0 path_provider: ^2.1.0 flutter_dotenv: ^5.1.0 transparent_image: ^2.0.1 @@ -33,10 +36,14 @@ dependencies: dependency_overrides: datadog_flutter_plugin: path: ../../packages/datadog_flutter_plugin + datadog_flags: + path: ../../packages/datadog_flags dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter_lints: ^5.0.0 flutter: diff --git a/examples/simple_example/test/flags_dogfood_web_test.dart b/examples/simple_example/test/flags_dogfood_web_test.dart new file mode 100644 index 000000000..33f8873e6 --- /dev/null +++ b/examples/simple_example/test/flags_dogfood_web_test.dart @@ -0,0 +1,59 @@ +// 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:flutter_test/flutter_test.dart'; +import 'package:test_app/flags/flags_demo_runtime.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('web local flags collector evaluates flags and counts emissions', + () async { + FlagsDemoRuntime? flagsRuntime; + try { + flagsRuntime = await FlagsDemoRuntime.create(); + await DatadogFlags.enable(configuration: flagsRuntime.configuration); + final client = DatadogFlags.sharedClient(); + + await client.setEvaluationContext(const DatadogFlagsEvaluationContext( + targetingKey: 'flutter-user', + attributes: { + 'plan': 'dogfood', + 'platform': 'flutter', + }, + )); + + final enabled = client.getBooleanDetails( + key: 'flutter.demo.enabled', + defaultValue: false, + ); + final title = client.getStringDetails( + key: 'flutter.demo.title', + defaultValue: 'Fallback title', + ); + client.getIntegerDetails(key: 'flutter.demo.limit', defaultValue: 0); + client.getDoubleDetails(key: 'flutter.demo.ratio', defaultValue: 0); + client.getObjectDetails(key: 'flutter.demo.config', defaultValue: {}); + + expect(enabled.value, isTrue); + expect(enabled.variant, 'enabled'); + expect(title.value, 'Datadog Flags'); + expect(title.variant, 'copy-a'); + + await Future.delayed(Duration.zero); + await client.flush(); + + final collector = flagsRuntime.collector; + expect(collector, isNotNull); + expect(collector!.exposureCount, 5); + expect(collector.evaluationRequestCount, 1); + expect(collector.evaluationEventCount, 5); + } finally { + await DatadogFlags.disable(); + await flagsRuntime?.stop(); + } + }); +} diff --git a/packages/datadog_flags/.gitignore b/packages/datadog_flags/.gitignore new file mode 100644 index 000000000..dd5eb9895 --- /dev/null +++ b/packages/datadog_flags/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/packages/datadog_flags/.metadata b/packages/datadog_flags/.metadata new file mode 100644 index 000000000..8e51da2fa --- /dev/null +++ b/packages/datadog_flags/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "924134a44c189315be2148659913dda1671cbe99" + channel: "stable" + +project_type: package diff --git a/packages/datadog_flags/CHANGELOG.md b/packages/datadog_flags/CHANGELOG.md new file mode 100644 index 000000000..72dcbefea --- /dev/null +++ b/packages/datadog_flags/CHANGELOG.md @@ -0,0 +1,6 @@ +## 0.0.1 + +- Add Dart-native precomputed assignment fetching and typed flag evaluation. +- Add persisted last-known assignments scoped to the active evaluation context. +- Add RUM feature flag reporting, exposure emission, and evaluation metric aggregation. +- Add fixture-backed VM and Chrome tests plus local example-app dogfood coverage. diff --git a/packages/datadog_flags/LICENSE b/packages/datadog_flags/LICENSE new file mode 100644 index 000000000..ba75c69f7 --- /dev/null +++ b/packages/datadog_flags/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/packages/datadog_flags/README.md b/packages/datadog_flags/README.md new file mode 100644 index 000000000..37256245f --- /dev/null +++ b/packages/datadog_flags/README.md @@ -0,0 +1,76 @@ +# Datadog Flags for Flutter + +`datadog_flags` is a Dart-native Flutter client for Datadog feature flags. It +fetches precomputed assignments from Datadog, resolves typed values locally, and +reports RUM feature flag evaluations, exposures, and evaluation metrics. + +This package does not bridge to native iOS or Android flagging SDKs. + +## Setup + +Initialize the Datadog Flutter SDK first, then enable flags: + +```dart +await DatadogSdk.instance.initialize(configuration, TrackingConsent.granted); +await DatadogFlags.enable(); + +final flags = DatadogFlags.sharedClient(); +await flags.setEvaluationContext(const DatadogFlagsEvaluationContext( + targetingKey: 'user-123', + attributes: {'plan': 'pro'}, +)); +``` + +## Typed Evaluation + +Use typed getters for values or details: + +```dart +final enabled = flags.getBooleanValue( + key: 'checkout.enabled', + defaultValue: false, +); + +final title = flags.getStringDetails( + key: 'checkout.title', + defaultValue: 'Checkout', +); +``` + +Details include the resolved value, variation key, reason, and any evaluation +error: + +- `providerNotReady`: no evaluation context has been loaded. +- `flagNotFound`: the current precomputed assignments do not include the flag. +- `typeMismatch`: the assignment type does not match the typed getter. + +## Behavior + +- Assignments are fetched from `/precompute-assignments`. +- Values are resolved synchronously after `setEvaluationContext` completes. +- Last-known assignments are persisted and restored only when the cached context + matches the active context. +- Gov sites fall back to the US1 flags endpoint. +- Exposures are sent only for successful typed evaluations where `doLog` is + true, deduped by `targetingKey + flagKey + allocationKey + variationKey`. +- Evaluation metrics aggregate successful, defaulted, and error evaluations and + flush on `flush()` or the configured interval. + +## Local Validation + +From this package: + +```bash +dart analyze . +flutter test test +flutter test --platform chrome test +``` + +The example app includes a local fixture mode for iOS, Android, and web: + +```bash +cd ../../examples/simple_example +flutter test integration_test/flags_dogfood_test.dart -d --dart-define FLAGS_MODE=local +flutter test integration_test/flags_dogfood_test.dart -d --dart-define FLAGS_MODE=local +flutter test --platform chrome test/flags_dogfood_web_test.dart --dart-define FLAGS_MODE=local +``` diff --git a/packages/datadog_flags/analysis_options.yaml b/packages/datadog_flags/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/packages/datadog_flags/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/datadog_flags/lib/datadog_flags.dart b/packages/datadog_flags/lib/datadog_flags.dart new file mode 100644 index 000000000..d4a8aa57e --- /dev/null +++ b/packages/datadog_flags/lib/datadog_flags.dart @@ -0,0 +1,19 @@ +// 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 'src/flags_client.dart'; +import 'src/flags_context.dart'; + +export 'src/datadog_flags.dart'; +export 'src/datadog_context.dart'; +export 'src/flags_client.dart'; +export 'src/flags_configuration.dart'; +export 'src/flags_context.dart'; +export 'src/flags_details.dart'; +export 'src/flags_error.dart'; +export 'src/flags_store.dart'; + +typedef FlagsClient = DatadogFlagsClient; +typedef FlagsEvaluationContext = DatadogFlagsEvaluationContext; diff --git a/packages/datadog_flags/lib/src/assignment.dart b/packages/datadog_flags/lib/src/assignment.dart new file mode 100644 index 000000000..f086af00c --- /dev/null +++ b/packages/datadog_flags/lib/src/assignment.dart @@ -0,0 +1,119 @@ +// 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 'json_value.dart'; + +enum FlagVariationType { boolean, string, integer, float, object, unknown } + +class FlagAssignment { + final String allocationKey; + final String variationKey; + final FlagVariationType variationType; + final Object? variationValue; + final String reason; + final bool doLog; + + const FlagAssignment({ + required this.allocationKey, + required this.variationKey, + required this.variationType, + required this.variationValue, + required this.reason, + required this.doLog, + }); + + factory FlagAssignment.fromJson(Map json) { + final typeName = json['variationType'] as String?; + final value = json['variationValue']; + final variationType = normalizeVariationType(typeName, value); + + return FlagAssignment( + allocationKey: json['allocationKey'] as String, + variationKey: json['variationKey'] as String, + variationType: variationType, + variationValue: _decodeVariationValue(variationType, value), + reason: json['reason'] as String, + doLog: json['doLog'] as bool, + ); + } + + Map toJson() { + return { + 'allocationKey': allocationKey, + 'variationKey': variationKey, + 'variationType': variationTypeToString(variationType), + 'variationValue': sanitizeJsonValue(variationValue), + 'reason': reason, + 'doLog': doLog, + }; + } + + Object? typedValue(FlagVariationType requestedType) { + if (variationType != requestedType) { + return null; + } + + return switch (requestedType) { + FlagVariationType.boolean when variationValue is bool => variationValue, + FlagVariationType.string when variationValue is String => variationValue, + FlagVariationType.integer when variationValue is int => variationValue, + FlagVariationType.float when variationValue is double => variationValue, + FlagVariationType.object => variationValue, + _ => null, + }; + } + + static const defaultAssignment = FlagAssignment( + allocationKey: '', + variationKey: '', + variationType: FlagVariationType.unknown, + variationValue: null, + reason: 'DEFAULT', + doLog: false, + ); +} + +FlagVariationType normalizeVariationType(String? typeName, Object? value) { + if (typeName == 'number') { + if (value is int) { + return FlagVariationType.integer; + } + if (value is num) { + return FlagVariationType.float; + } + } + return variationTypeFromString(typeName); +} + +FlagVariationType variationTypeFromString(String? value) { + return switch (value) { + 'boolean' => FlagVariationType.boolean, + 'string' => FlagVariationType.string, + 'integer' => FlagVariationType.integer, + 'float' => FlagVariationType.float, + 'object' => FlagVariationType.object, + _ => FlagVariationType.unknown, + }; +} + +String variationTypeToString(FlagVariationType type) { + return switch (type) { + FlagVariationType.boolean => 'boolean', + FlagVariationType.string => 'string', + FlagVariationType.integer => 'integer', + FlagVariationType.float => 'float', + FlagVariationType.object => 'object', + FlagVariationType.unknown => 'unknown', + }; +} + +Object? _decodeVariationValue(FlagVariationType type, Object? value) { + return switch (type) { + FlagVariationType.integer when value is int => value, + FlagVariationType.float when value is num => value.toDouble(), + FlagVariationType.object => sanitizeJsonValue(value), + _ => value, + }; +} diff --git a/packages/datadog_flags/lib/src/datadog_context.dart b/packages/datadog_flags/lib/src/datadog_context.dart new file mode 100644 index 000000000..625584611 --- /dev/null +++ b/packages/datadog_flags/lib/src/datadog_context.dart @@ -0,0 +1,114 @@ +// 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_flutter_plugin/datadog_flutter_plugin.dart'; +import 'package:flutter/foundation.dart'; + +class DatadogFlagsContext { + final String clientToken; + final String env; + final DatadogSite site; + final String? service; + final String? version; + final String? applicationId; + final String sdkVersion; + final String source; + + const DatadogFlagsContext({ + required this.clientToken, + required this.env, + required this.site, + required this.sdkVersion, + this.service, + this.version, + this.applicationId, + this.source = 'flutter', + }); + + factory DatadogFlagsContext.fromSdk(DatadogSdk sdk) { + final configuration = sdk.configuration; + if (configuration == null) { + throw StateError( + 'DatadogSdk must be initialized before enabling DatadogFlags.', + ); + } + + return DatadogFlagsContext( + clientToken: configuration.clientToken, + env: configuration.env, + site: configuration.site, + service: configuration.service, + version: configuration.versionTag, + applicationId: configuration.rumConfiguration?.applicationId, + sdkVersion: DatadogSdk.sdkVersion, + ); + } + + Uri flagsEndpoint() { + return switch (site) { + DatadogSite.us1 => Uri.parse('https://preview.ff-cdn.datadoghq.com'), + DatadogSite.us3 => Uri.parse('https://preview.ff-cdn.us3.datadoghq.com'), + DatadogSite.us5 => Uri.parse('https://preview.ff-cdn.us5.datadoghq.com'), + DatadogSite.eu1 => Uri.parse('https://preview.ff-cdn.datadoghq.eu'), + DatadogSite.ap1 => Uri.parse('https://preview.ff-cdn.ap1.datadoghq.com'), + DatadogSite.ap2 => Uri.parse('https://preview.ff-cdn.ap2.datadoghq.com'), + DatadogSite.us1Fed => Uri.parse('https://preview.ff-cdn.datadoghq.com'), + }; + } + + Uri intakeEndpoint() { + return switch (site) { + DatadogSite.us1 => Uri.parse('https://browser-intake-datadoghq.com'), + DatadogSite.us3 => Uri.parse('https://browser-intake-us3-datadoghq.com'), + DatadogSite.us5 => Uri.parse('https://browser-intake-us5-datadoghq.com'), + DatadogSite.eu1 => Uri.parse('https://browser-intake-datadoghq.eu'), + DatadogSite.ap1 => Uri.parse('https://browser-intake-ap1-datadoghq.com'), + DatadogSite.ap2 => Uri.parse('https://browser-intake-ap2-datadoghq.com'), + DatadogSite.us1Fed => Uri.parse('https://browser-intake-ddog-gov.com'), + }; + } + + Map evaluationBatchContext() { + final rumContext = applicationId == null + ? null + : { + 'application': {'id': applicationId}, + 'view': null, + }; + + return removeNullValues({ + 'geo': null, + 'device': { + 'name': defaultTargetPlatform.name, + 'type': _deviceTypeForTargetPlatform(defaultTargetPlatform), + 'brand': '', + 'model': defaultTargetPlatform.name, + }, + 'os': { + 'name': defaultTargetPlatform.name, + 'version': '', + }, + 'service': service ?? '', + 'version': version ?? '', + 'env': env, + 'rum': rumContext, + }); + } +} + +String _deviceTypeForTargetPlatform(TargetPlatform platform) { + return switch (platform) { + TargetPlatform.android || TargetPlatform.iOS => 'mobile', + TargetPlatform.macOS || + TargetPlatform.windows || + TargetPlatform.linux => + 'desktop', + TargetPlatform.fuchsia => 'other', + }; +} + +Map removeNullValues(Map input) { + return Map.fromEntries(input.entries.where((entry) => entry.value != null)); +} diff --git a/packages/datadog_flags/lib/src/datadog_flags.dart b/packages/datadog_flags/lib/src/datadog_flags.dart new file mode 100644 index 000000000..c0f1b3544 --- /dev/null +++ b/packages/datadog_flags/lib/src/datadog_flags.dart @@ -0,0 +1,172 @@ +// 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_flutter_plugin/datadog_flutter_plugin.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'datadog_context.dart'; +import 'evaluation_aggregator.dart'; +import 'exposure_logger.dart'; +import 'flag_assignments_fetcher.dart'; +import 'flags_client.dart'; +import 'flags_configuration.dart'; +import 'flags_error.dart'; +import 'flags_repository.dart'; +import 'flags_store.dart'; +import 'rum_flag_evaluation_reporter.dart'; + +class DatadogFlags { + static DatadogFlagsConfiguration _configuration = + const DatadogFlagsConfiguration(); + static DatadogFlagsContext? _datadogContext; + static http.Client? _httpClient; + static DatadogFlagsStore? _store; + static final Map _clients = {}; + static bool _enabled = false; + + DatadogFlags._(); + + static bool get isEnabled => _enabled; + + static Future enable({ + DatadogFlagsConfiguration configuration = const DatadogFlagsConfiguration(), + DatadogSdk? sdk, + }) async { + await _disposeClients(); + _httpClient?.close(); + + final datadogSdk = sdk ?? DatadogSdk.instance; + final datadogContext = + configuration.datadogContext ?? DatadogFlagsContext.fromSdk(datadogSdk); + final prefs = configuration.store == null + ? await SharedPreferences.getInstance() + : null; + + _configuration = configuration.normalized(); + _datadogContext = datadogContext; + _httpClient = configuration.httpClient ?? http.Client(); + _store = configuration.store ?? + SharedPreferencesDatadogFlagsStore(sharedPreferences: prefs!); + _enabled = true; + await createClient(); + } + + static Future createClient({ + String name = DatadogFlagsClient.defaultName, + }) async { + final existing = _clients[name]; + if (existing != null) { + return existing; + } + + final runtime = _runtimeOrThrow(); + final repository = FlagsRepository( + clientName: name, + fetcher: FlagAssignmentsFetcher( + datadogContext: runtime.datadogContext, + configuration: runtime.configuration, + httpClient: runtime.httpClient, + ), + store: runtime.store, + dateProvider: runtime.configuration.dateProvider, + ); + await repository.restore(); + + final exposureLogger = ExposureLogger( + datadogContext: runtime.datadogContext, + configuration: runtime.configuration, + httpClient: runtime.httpClient, + ); + final evaluationAggregator = EvaluationAggregator( + datadogContext: runtime.datadogContext, + configuration: runtime.configuration, + httpClient: runtime.httpClient, + ); + + final client = DatadogFlagsClient( + name: name, + repository: repository, + exposureLogger: exposureLogger, + evaluationAggregator: evaluationAggregator, + rumFlagEvaluationReporter: DatadogRumFlagEvaluationReporter( + rum: DatadogSdk.instance.rum, + enabled: runtime.configuration.rumIntegrationEnabled, + ), + ); + _clients[name] = client; + return client; + } + + static DatadogFlagsClient sharedClient({ + String name = DatadogFlagsClient.defaultName, + }) { + final client = _clients[name]; + if (client == null) { + throw FlagsException.clientNotInitialized( + 'DatadogFlagsClient named "$name" has not been created.', + ); + } + return client; + } + + static Future flush() async { + await Future.wait(_clients.values.map((client) => client.flush())); + } + + static Future reset() async { + await Future.wait(_clients.values.map((client) => client.reset())); + _clients.clear(); + } + + static Future disable() async { + await _disposeClients(); + _httpClient?.close(); + _httpClient = null; + _store = null; + _datadogContext = null; + _enabled = false; + } + + static Future _disposeClients() async { + await Future.wait(_clients.values.map((client) => client.dispose())); + _clients.clear(); + } + + static _FlagsRuntime _runtimeOrThrow() { + final datadogContext = _datadogContext; + final httpClient = _httpClient; + final store = _store; + if (!_enabled || + datadogContext == null || + httpClient == null || + store == null) { + throw FlagsException.clientNotInitialized( + 'Call DatadogFlags.enable() before creating a flags client.', + ); + } + + return _FlagsRuntime( + configuration: _configuration, + datadogContext: datadogContext, + httpClient: httpClient, + store: store, + ); + } +} + +class _FlagsRuntime { + final DatadogFlagsConfiguration configuration; + final DatadogFlagsContext datadogContext; + final http.Client httpClient; + final DatadogFlagsStore store; + + const _FlagsRuntime({ + required this.configuration, + required this.datadogContext, + required this.httpClient, + required this.store, + }); +} diff --git a/packages/datadog_flags/lib/src/evaluation_aggregator.dart b/packages/datadog_flags/lib/src/evaluation_aggregator.dart new file mode 100644 index 000000000..f54c19ba4 --- /dev/null +++ b/packages/datadog_flags/lib/src/evaluation_aggregator.dart @@ -0,0 +1,208 @@ +// 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:http/http.dart' as http; +import 'package:uuid/uuid.dart'; + +import 'assignment.dart'; +import 'datadog_context.dart'; +import 'flags_configuration.dart'; +import 'flags_context.dart'; +import 'json_value.dart'; + +class EvaluationAggregator { + final DatadogFlagsContext datadogContext; + final DatadogFlagsConfiguration configuration; + final http.Client httpClient; + final Map _aggregations = {}; + Timer? _flushTimer; + + EvaluationAggregator({ + required this.datadogContext, + required this.configuration, + required this.httpClient, + }) { + if (configuration.trackEvaluations) { + _flushTimer = Timer.periodic( + configuration.evaluationFlushInterval, + (_) => unawaited(flush()), + ); + } + } + + void recordEvaluation({ + required String flagKey, + required FlagAssignment assignment, + required DatadogFlagsEvaluationContext evaluationContext, + required String? error, + }) { + if (!configuration.trackEvaluations) { + return; + } + + final now = configuration.dateProvider().millisecondsSinceEpoch; + final key = _aggregationKey( + flagKey: flagKey, + assignment: assignment, + evaluationContext: evaluationContext, + error: error, + ); + final existing = _aggregations[key]; + if (existing != null) { + existing.evaluationCount += 1; + existing.lastEvaluation = now; + return; + } + + final runtimeDefaultUsed = assignment.reason == 'DEFAULT' || error != null; + _aggregations[key] = _AggregatedEvaluation( + flagKey: flagKey, + variantKey: assignment.variationKey, + allocationKey: assignment.allocationKey, + targetingKey: evaluationContext.targetingKey, + error: error, + attributes: evaluationContext.attributes, + firstEvaluation: now, + lastEvaluation: now, + evaluationCount: 1, + runtimeDefaultUsed: runtimeDefaultUsed, + ); + + if (_aggregations.length >= configuration.evaluationMaxBatchSize) { + unawaited(flush()); + } + } + + Future flush() async { + if (!configuration.trackEvaluations || _aggregations.isEmpty) { + return; + } + + final evaluations = List<_AggregatedEvaluation>.from(_aggregations.values); + _aggregations.clear(); + + final endpoint = configuration.customEvaluationEndpoint ?? + datadogContext.intakeEndpoint().replace( + path: '/api/v2/flagevaluation', + queryParameters: {'ddsource': datadogContext.source}, + ); + final body = jsonEncode({ + 'context': datadogContext.evaluationBatchContext(), + 'flagEvaluations': evaluations.map((e) => e.toJson()).toList(), + }); + + try { + final response = await httpClient.post( + endpoint, + headers: { + 'Content-Type': 'application/json', + 'DD-API-KEY': datadogContext.clientToken, + 'DD-EVP-ORIGIN': datadogContext.source, + 'DD-EVP-ORIGIN-VERSION': datadogContext.sdkVersion, + 'DD-REQUEST-ID': const Uuid().v4(), + }, + body: body, + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + _restore(evaluations); + } + } catch (_) { + _restore(evaluations); + } + } + + void dispose() { + _flushTimer?.cancel(); + _flushTimer = null; + } + + void _restore(List<_AggregatedEvaluation> evaluations) { + for (final evaluation in evaluations) { + _aggregations[_aggregationKey( + flagKey: evaluation.flagKey, + assignment: FlagAssignment( + allocationKey: evaluation.allocationKey, + variationKey: evaluation.variantKey, + variationType: FlagVariationType.unknown, + variationValue: null, + reason: evaluation.runtimeDefaultUsed ? 'DEFAULT' : '', + doLog: false, + ), + evaluationContext: DatadogFlagsEvaluationContext( + targetingKey: evaluation.targetingKey, + attributes: evaluation.attributes, + ), + error: evaluation.error, + )] = evaluation; + } + } +} + +String _aggregationKey({ + required String flagKey, + required FlagAssignment assignment, + required DatadogFlagsEvaluationContext evaluationContext, + required String? error, +}) { + return jsonEncode({ + 'flagKey': flagKey, + 'variantKey': assignment.variationKey, + 'allocationKey': assignment.allocationKey, + 'targetingKey': evaluationContext.targetingKey, + 'error': error, + 'context': sortedJson(evaluationContext.attributes), + }); +} + +class _AggregatedEvaluation { + final String flagKey; + final String variantKey; + final String allocationKey; + final String targetingKey; + final String? error; + final Map attributes; + final int firstEvaluation; + int lastEvaluation; + int evaluationCount; + final bool runtimeDefaultUsed; + + _AggregatedEvaluation({ + required this.flagKey, + required this.variantKey, + required this.allocationKey, + required this.targetingKey, + required this.error, + required this.attributes, + required this.firstEvaluation, + required this.lastEvaluation, + required this.evaluationCount, + required this.runtimeDefaultUsed, + }); + + Map toJson() { + return removeNullValues({ + 'timestamp': firstEvaluation, + 'flag': {'key': flagKey}, + 'first_evaluation': firstEvaluation, + 'last_evaluation': lastEvaluation, + 'evaluation_count': evaluationCount, + 'variant': runtimeDefaultUsed ? null : {'key': variantKey}, + 'allocation': runtimeDefaultUsed ? null : {'key': allocationKey}, + 'targeting_rule': null, + 'targeting_key': targetingKey, + 'runtime_default_used': runtimeDefaultUsed ? true : null, + 'error': error == null ? null : {'message': error}, + 'context': attributes.isEmpty + ? null + : { + 'evaluation': sanitizeJsonValue(attributes), + 'dd': null, + }, + }); + } +} diff --git a/packages/datadog_flags/lib/src/exposure_logger.dart b/packages/datadog_flags/lib/src/exposure_logger.dart new file mode 100644 index 000000000..7cf540e9f --- /dev/null +++ b/packages/datadog_flags/lib/src/exposure_logger.dart @@ -0,0 +1,83 @@ +// 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:http/http.dart' as http; +import 'package:uuid/uuid.dart'; + +import 'assignment.dart'; +import 'datadog_context.dart'; +import 'flags_configuration.dart'; +import 'flags_context.dart'; +import 'json_value.dart'; + +class ExposureLogger { + final DatadogFlagsContext datadogContext; + final DatadogFlagsConfiguration configuration; + final http.Client httpClient; + final Set _loggedExposures = {}; + + ExposureLogger({ + required this.datadogContext, + required this.configuration, + required this.httpClient, + }); + + Future logExposure({ + required String flagKey, + required FlagAssignment assignment, + required DatadogFlagsEvaluationContext evaluationContext, + }) async { + if (!configuration.trackExposures || !assignment.doLog) { + return; + } + + final exposureKey = [ + evaluationContext.targetingKey, + flagKey, + assignment.allocationKey, + assignment.variationKey, + ].join('|'); + if (!_loggedExposures.add(exposureKey)) { + return; + } + + final endpoint = configuration.customExposureEndpoint ?? + datadogContext.intakeEndpoint().replace( + path: '/api/v2/exposures', + queryParameters: {'ddsource': datadogContext.source}, + ); + final event = { + 'timestamp': configuration.dateProvider().millisecondsSinceEpoch, + 'allocation': {'key': assignment.allocationKey}, + 'flag': {'key': flagKey}, + 'variant': {'key': assignment.variationKey}, + 'subject': { + 'id': evaluationContext.targetingKey, + 'attributes': sanitizeJsonValue(evaluationContext.attributes), + }, + }; + + try { + final response = await httpClient.post( + endpoint, + headers: { + 'Content-Type': 'text/plain;charset=UTF-8', + 'DD-API-KEY': datadogContext.clientToken, + 'DD-EVP-ORIGIN': datadogContext.source, + 'DD-EVP-ORIGIN-VERSION': datadogContext.sdkVersion, + 'DD-REQUEST-ID': const Uuid().v4(), + }, + body: jsonEncode(event), + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + _loggedExposures.remove(exposureKey); + } + } catch (_) { + _loggedExposures.remove(exposureKey); + } + } +} diff --git a/packages/datadog_flags/lib/src/flag_assignments_fetcher.dart b/packages/datadog_flags/lib/src/flag_assignments_fetcher.dart new file mode 100644 index 000000000..bf56ad680 --- /dev/null +++ b/packages/datadog_flags/lib/src/flag_assignments_fetcher.dart @@ -0,0 +1,127 @@ +// 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:http/http.dart' as http; + +import 'assignment.dart'; +import 'datadog_context.dart'; +import 'flags_configuration.dart'; +import 'flags_context.dart'; +import 'flags_error.dart'; +import 'json_value.dart'; + +class FlagAssignmentsFetcher { + final DatadogFlagsContext datadogContext; + final DatadogFlagsConfiguration configuration; + final http.Client httpClient; + + FlagAssignmentsFetcher({ + required this.datadogContext, + required this.configuration, + required this.httpClient, + }); + + Future> fetch( + DatadogFlagsEvaluationContext evaluationContext, + ) async { + final endpoint = configuration.customFlagsEndpoint ?? + datadogContext.flagsEndpoint().replace( + path: '/precompute-assignments', + ); + final response = await httpClient.post( + endpoint, + headers: _headers(), + body: jsonEncode(_requestBody(evaluationContext)), + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw FlagsException.networkError( + 'Unexpected flag assignments response status ${response.statusCode}.', + ); + } + + try { + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as Map; + final attributes = data['attributes'] as Map; + final flags = attributes['flags'] as Map; + final assignments = {}; + for (final entry in flags.entries) { + final assignmentJson = entry.value as Map; + final variationType = normalizeVariationType( + assignmentJson['variationType'] as String?, + assignmentJson['variationValue'], + ); + if (variationType == FlagVariationType.unknown) { + continue; + } + assignments[entry.key] = FlagAssignment( + allocationKey: assignmentJson['allocationKey'] as String, + variationKey: assignmentJson['variationKey'] as String, + variationType: variationType, + variationValue: _valueForType( + variationType, + assignmentJson['variationValue'], + ), + reason: assignmentJson['reason'] as String, + doLog: assignmentJson['doLog'] as bool, + ); + } + return assignments; + } catch (error) { + throw FlagsException.invalidResponse( + 'Failed to decode flag assignments response: $error', + ); + } + } + + Map _headers() { + return { + 'Content-Type': 'application/vnd.api+json', + 'Accept-Encoding': 'gzip, deflate, br', + 'dd-client-token': datadogContext.clientToken, + if (datadogContext.applicationId != null) + 'dd-application-id': datadogContext.applicationId!, + ...?configuration.customFlagsHeaders, + }; + } + + Map _requestBody( + DatadogFlagsEvaluationContext evaluationContext, + ) { + return { + 'data': { + 'type': 'precompute-assignments-request', + 'attributes': { + 'env': { + 'name': datadogContext.env, + 'dd_env': datadogContext.env, + }, + 'source': { + 'sdk_name': 'dd-sdk-flutter', + 'sdk_version': datadogContext.sdkVersion, + }, + 'subject': { + 'targeting_key': evaluationContext.targetingKey, + 'targeting_attributes': sanitizeJsonValue( + evaluationContext.attributes, + ), + }, + }, + }, + }; + } +} + +Object? _valueForType(FlagVariationType type, Object? value) { + return switch (type) { + FlagVariationType.integer when value is int => value, + FlagVariationType.float when value is num => value.toDouble(), + FlagVariationType.object => sanitizeJsonValue(value), + _ => value, + }; +} diff --git a/packages/datadog_flags/lib/src/flags_client.dart b/packages/datadog_flags/lib/src/flags_client.dart new file mode 100644 index 000000000..7bf31bb2d --- /dev/null +++ b/packages/datadog_flags/lib/src/flags_client.dart @@ -0,0 +1,264 @@ +// 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 'assignment.dart'; +import 'datadog_flags.dart'; +import 'evaluation_aggregator.dart'; +import 'exposure_logger.dart'; +import 'flags_context.dart'; +import 'flags_details.dart'; +import 'flags_error.dart'; +import 'flags_repository.dart'; +import 'json_value.dart'; +import 'rum_flag_evaluation_reporter.dart'; + +class DatadogFlagsClient { + static const defaultName = 'default'; + + final String name; + final FlagsRepository _repository; + final ExposureLogger _exposureLogger; + final EvaluationAggregator _evaluationAggregator; + final RumFlagEvaluationReporter _rumFlagEvaluationReporter; + + DatadogFlagsClient({ + required this.name, + required FlagsRepository repository, + required ExposureLogger exposureLogger, + required EvaluationAggregator evaluationAggregator, + required RumFlagEvaluationReporter rumFlagEvaluationReporter, + }) : _repository = repository, + _exposureLogger = exposureLogger, + _evaluationAggregator = evaluationAggregator, + _rumFlagEvaluationReporter = rumFlagEvaluationReporter; + + static Future create({ + String name = defaultName, + }) { + return DatadogFlags.createClient(name: name); + } + + static DatadogFlagsClient shared({ + String name = defaultName, + }) { + return DatadogFlags.sharedClient(name: name); + } + + Future setEvaluationContext( + DatadogFlagsEvaluationContext context, + ) async { + await _repository.setEvaluationContext(context); + } + + FlagDetails getBooleanDetails({ + required String key, + required bool defaultValue, + }) { + return _getDetails( + key: key, + defaultValue: defaultValue, + requestedType: FlagVariationType.boolean, + ); + } + + bool getBooleanValue({ + required String key, + required bool defaultValue, + }) { + return getBooleanDetails(key: key, defaultValue: defaultValue).value; + } + + FlagDetails getStringDetails({ + required String key, + required String defaultValue, + }) { + return _getDetails( + key: key, + defaultValue: defaultValue, + requestedType: FlagVariationType.string, + ); + } + + String getStringValue({ + required String key, + required String defaultValue, + }) { + return getStringDetails(key: key, defaultValue: defaultValue).value; + } + + FlagDetails getIntegerDetails({ + required String key, + required int defaultValue, + }) { + return _getDetails( + key: key, + defaultValue: defaultValue, + requestedType: FlagVariationType.integer, + ); + } + + int getIntegerValue({ + required String key, + required int defaultValue, + }) { + return getIntegerDetails(key: key, defaultValue: defaultValue).value; + } + + FlagDetails getDoubleDetails({ + required String key, + required double defaultValue, + }) { + return _getDetails( + key: key, + defaultValue: defaultValue, + requestedType: FlagVariationType.float, + ); + } + + double getDoubleValue({ + required String key, + required double defaultValue, + }) { + return getDoubleDetails(key: key, defaultValue: defaultValue).value; + } + + FlagDetails getObjectDetails({ + required String key, + required Object? defaultValue, + }) { + return _getDetails( + key: key, + defaultValue: sanitizeJsonValue(defaultValue), + requestedType: FlagVariationType.object, + ); + } + + Object? getObjectValue({ + required String key, + required Object? defaultValue, + }) { + return getObjectDetails(key: key, defaultValue: defaultValue).value; + } + + Future flush() { + return _evaluationAggregator.flush(); + } + + Future reset() async { + _evaluationAggregator.dispose(); + await _repository.reset(); + } + + Future dispose() async { + _evaluationAggregator.dispose(); + } + + FlagDetails _getDetails({ + required String key, + required T defaultValue, + required FlagVariationType requestedType, + }) { + final context = _repository.context; + if (context == null) { + _evaluationAggregator.recordEvaluation( + flagKey: key, + assignment: FlagAssignment.defaultAssignment, + evaluationContext: DatadogFlagsEvaluationContext.empty, + error: EvaluationErrorCode.providerNotReady, + ); + return FlagDetails( + key: key, + value: defaultValue, + error: FlagEvaluationError.providerNotReady, + ); + } + + final assignment = _repository.flagAssignment(key); + if (assignment == null) { + _evaluationAggregator.recordEvaluation( + flagKey: key, + assignment: FlagAssignment.defaultAssignment, + evaluationContext: context, + error: EvaluationErrorCode.flagNotFound, + ); + return FlagDetails( + key: key, + value: defaultValue, + error: FlagEvaluationError.flagNotFound, + ); + } + + if (requestedType == FlagVariationType.object && + assignment.variationType != FlagVariationType.object) { + _evaluationAggregator.recordEvaluation( + flagKey: key, + assignment: assignment, + evaluationContext: context, + error: EvaluationErrorCode.typeMismatch, + ); + return FlagDetails( + key: key, + value: defaultValue, + error: FlagEvaluationError.typeMismatch, + ); + } + + final typedValue = assignment.typedValue(requestedType); + if (typedValue == null && requestedType != FlagVariationType.object) { + _evaluationAggregator.recordEvaluation( + flagKey: key, + assignment: assignment, + evaluationContext: context, + error: EvaluationErrorCode.typeMismatch, + ); + return FlagDetails( + key: key, + value: defaultValue, + error: FlagEvaluationError.typeMismatch, + ); + } + + final value = typedValue as T; + _trackEvaluation(key, assignment, value, context); + return FlagDetails( + key: key, + value: value, + variant: assignment.variationKey, + reason: assignment.reason, + ); + } + + void _trackEvaluation( + String key, + FlagAssignment assignment, + T value, + DatadogFlagsEvaluationContext context, + ) { + unawaited(_exposureLogger.logExposure( + flagKey: key, + assignment: assignment, + evaluationContext: context, + )); + _evaluationAggregator.recordEvaluation( + flagKey: key, + assignment: assignment, + evaluationContext: context, + error: null, + ); + if (value != null) { + _rumFlagEvaluationReporter.report(key, value as Object); + } + } +} + +class EvaluationErrorCode { + static const providerNotReady = 'PROVIDER_NOT_READY'; + static const flagNotFound = 'FLAG_NOT_FOUND'; + static const typeMismatch = 'TYPE_MISMATCH'; + + EvaluationErrorCode._(); +} diff --git a/packages/datadog_flags/lib/src/flags_configuration.dart b/packages/datadog_flags/lib/src/flags_configuration.dart new file mode 100644 index 000000000..a4ee113d4 --- /dev/null +++ b/packages/datadog_flags/lib/src/flags_configuration.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 'package:http/http.dart' as http; + +import 'datadog_context.dart'; +import 'flags_store.dart'; + +class DatadogFlagsConfiguration { + final Uri? customFlagsEndpoint; + final Map? customFlagsHeaders; + final Uri? customExposureEndpoint; + final bool trackExposures; + final Uri? customEvaluationEndpoint; + final bool trackEvaluations; + final Duration evaluationFlushInterval; + final bool rumIntegrationEnabled; + final DatadogFlagsStore? store; + final http.Client? httpClient; + final DatadogFlagsContext? datadogContext; + final DateTime Function() dateProvider; + final int evaluationMaxBatchSize; + + const DatadogFlagsConfiguration({ + this.customFlagsEndpoint, + this.customFlagsHeaders, + this.customExposureEndpoint, + this.trackExposures = true, + this.customEvaluationEndpoint, + this.trackEvaluations = true, + this.evaluationFlushInterval = const Duration(seconds: 10), + this.rumIntegrationEnabled = true, + this.store, + this.httpClient, + this.datadogContext, + this.dateProvider = DateTime.now, + this.evaluationMaxBatchSize = 1000, + }); + + DatadogFlagsConfiguration normalized() { + return DatadogFlagsConfiguration( + customFlagsEndpoint: customFlagsEndpoint, + customFlagsHeaders: customFlagsHeaders, + customExposureEndpoint: customExposureEndpoint, + trackExposures: trackExposures, + customEvaluationEndpoint: customEvaluationEndpoint, + trackEvaluations: trackEvaluations, + evaluationFlushInterval: _clamp(evaluationFlushInterval, min: 1, max: 60), + rumIntegrationEnabled: rumIntegrationEnabled, + store: store, + httpClient: httpClient, + datadogContext: datadogContext, + dateProvider: dateProvider, + evaluationMaxBatchSize: evaluationMaxBatchSize, + ); + } +} + +Duration _clamp(Duration value, {required int min, required int max}) { + if (value < Duration(seconds: min)) { + return Duration(seconds: min); + } + if (value > Duration(seconds: max)) { + return Duration(seconds: max); + } + return value; +} diff --git a/packages/datadog_flags/lib/src/flags_context.dart b/packages/datadog_flags/lib/src/flags_context.dart new file mode 100644 index 000000000..8db9f844d --- /dev/null +++ b/packages/datadog_flags/lib/src/flags_context.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 'json_value.dart'; + +class DatadogFlagsEvaluationContext { + static const empty = DatadogFlagsEvaluationContext(targetingKey: ''); + + final String targetingKey; + final Map attributes; + + const DatadogFlagsEvaluationContext({ + required this.targetingKey, + this.attributes = const {}, + }); + + factory DatadogFlagsEvaluationContext.fromJson(Map json) { + return DatadogFlagsEvaluationContext( + targetingKey: json['targetingKey'] as String, + attributes: Map.from( + json['attributes'] as Map? ?? const {}, + ), + ); + } + + Map toJson() { + return { + 'targetingKey': targetingKey, + 'attributes': sanitizeJsonValue(attributes), + }; + } +} diff --git a/packages/datadog_flags/lib/src/flags_details.dart b/packages/datadog_flags/lib/src/flags_details.dart new file mode 100644 index 000000000..76dd8eb27 --- /dev/null +++ b/packages/datadog_flags/lib/src/flags_details.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 'flags_error.dart'; + +class FlagDetails { + final String key; + final T value; + final String? variant; + final String? reason; + final FlagEvaluationError? error; + + const FlagDetails({ + required this.key, + required this.value, + this.variant, + this.reason, + this.error, + }); +} diff --git a/packages/datadog_flags/lib/src/flags_error.dart b/packages/datadog_flags/lib/src/flags_error.dart new file mode 100644 index 000000000..8e852e73a --- /dev/null +++ b/packages/datadog_flags/lib/src/flags_error.dart @@ -0,0 +1,47 @@ +// 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. + +enum FlagEvaluationError { + providerNotReady, + flagNotFound, + typeMismatch, +} + +enum FlagsErrorType { + networkError, + invalidResponse, + clientNotInitialized, + invalidConfiguration, +} + +class FlagsException implements Exception { + final FlagsErrorType type; + final String message; + final Object? cause; + + const FlagsException(this.type, this.message, [this.cause]); + + factory FlagsException.networkError(String message, [Object? cause]) { + return FlagsException(FlagsErrorType.networkError, message, cause); + } + + factory FlagsException.invalidResponse(String message, [Object? cause]) { + return FlagsException(FlagsErrorType.invalidResponse, message, cause); + } + + factory FlagsException.clientNotInitialized( + String message, [ + Object? cause, + ]) { + return FlagsException(FlagsErrorType.clientNotInitialized, message, cause); + } + + factory FlagsException.invalidConfiguration(String message, [Object? cause]) { + return FlagsException(FlagsErrorType.invalidConfiguration, message, cause); + } + + @override + String toString() => 'FlagsException($type): $message'; +} diff --git a/packages/datadog_flags/lib/src/flags_repository.dart b/packages/datadog_flags/lib/src/flags_repository.dart new file mode 100644 index 000000000..044922d16 --- /dev/null +++ b/packages/datadog_flags/lib/src/flags_repository.dart @@ -0,0 +1,56 @@ +// 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 'assignment.dart'; +import 'flag_assignments_fetcher.dart'; +import 'flags_context.dart'; +import 'flags_store.dart'; + +class FlagsRepository { + final String clientName; + final FlagAssignmentsFetcher fetcher; + final DatadogFlagsStore store; + final DateTime Function() dateProvider; + + FlagsData? _state; + int _contextRequestId = 0; + + FlagsRepository({ + required this.clientName, + required this.fetcher, + required this.store, + required this.dateProvider, + }); + + DatadogFlagsEvaluationContext? get context => _state?.context; + + FlagAssignment? flagAssignment(String key) => _state?.flags[key]; + + Future restore() async { + _state = await store.read(clientName); + } + + Future setEvaluationContext( + DatadogFlagsEvaluationContext context, + ) async { + final requestId = ++_contextRequestId; + final flags = await fetcher.fetch(context); + if (requestId != _contextRequestId) { + return; + } + + _state = FlagsData( + flags: flags, + context: context, + date: dateProvider(), + ); + await store.write(clientName, _state!); + } + + Future reset() async { + _state = null; + await store.delete(clientName); + } +} diff --git a/packages/datadog_flags/lib/src/flags_store.dart b/packages/datadog_flags/lib/src/flags_store.dart new file mode 100644 index 000000000..4589a9714 --- /dev/null +++ b/packages/datadog_flags/lib/src/flags_store.dart @@ -0,0 +1,105 @@ +// 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:shared_preferences/shared_preferences.dart'; + +import 'assignment.dart'; +import 'flags_context.dart'; + +abstract class DatadogFlagsStore { + Future read(String clientName); + Future write(String clientName, FlagsData data); + Future delete(String clientName); +} + +class FlagsData { + final Map flags; + final DatadogFlagsEvaluationContext context; + final DateTime date; + + const FlagsData({ + required this.flags, + required this.context, + required this.date, + }); + + factory FlagsData.fromJson(Map json) { + final flags = json['flags'] as Map? ?? const {}; + return FlagsData( + flags: flags.map((key, value) { + return MapEntry( + key, + FlagAssignment.fromJson(value as Map), + ); + }), + context: DatadogFlagsEvaluationContext.fromJson( + json['context'] as Map, + ), + date: DateTime.parse(json['date'] as String), + ); + } + + Map toJson() { + return { + 'flags': flags.map((key, value) => MapEntry(key, value.toJson())), + 'context': context.toJson(), + 'date': date.toIso8601String(), + }; + } +} + +class SharedPreferencesDatadogFlagsStore implements DatadogFlagsStore { + final SharedPreferences sharedPreferences; + final String namespace; + + SharedPreferencesDatadogFlagsStore({ + required this.sharedPreferences, + this.namespace = 'datadog_flags', + }); + + @override + Future read(String clientName) async { + final encoded = sharedPreferences.getString(_key(clientName)); + if (encoded == null) { + return null; + } + final decoded = jsonDecode(encoded) as Map; + return FlagsData.fromJson(decoded); + } + + @override + Future write(String clientName, FlagsData data) { + return sharedPreferences.setString( + _key(clientName), + jsonEncode(data.toJson()), + ); + } + + @override + Future delete(String clientName) { + return sharedPreferences.remove(_key(clientName)); + } + + String _key(String clientName) => '$namespace.$clientName'; +} + +class InMemoryDatadogFlagsStore implements DatadogFlagsStore { + final Map values = {}; + + @override + Future read(String clientName) async => values[clientName]; + + @override + Future write(String clientName, FlagsData data) async { + values[clientName] = data; + } + + @override + Future delete(String clientName) async { + values.remove(clientName); + } +} diff --git a/packages/datadog_flags/lib/src/json_value.dart b/packages/datadog_flags/lib/src/json_value.dart new file mode 100644 index 000000000..ed7ab5c35 --- /dev/null +++ b/packages/datadog_flags/lib/src/json_value.dart @@ -0,0 +1,43 @@ +// 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. + +Object? sanitizeJsonValue(Object? value) { + if (value == null || + value is String || + value is bool || + value is int || + value is double) { + return value; + } + if (value is num) { + return value.toDouble(); + } + if (value is Map) { + return value.map((key, value) { + if (key is! String) { + throw ArgumentError.value( + key, 'key', 'JSON object keys must be String'); + } + return MapEntry(key, sanitizeJsonValue(value)); + }); + } + if (value is Iterable) { + return value.map(sanitizeJsonValue).toList(); + } + throw ArgumentError.value(value, 'value', 'Unsupported JSON value'); +} + +Object? sortedJson(Object? value) { + if (value is Map) { + final sortedKeys = value.keys.toList()..sort(); + return { + for (final key in sortedKeys) key: sortedJson(value[key]), + }; + } + if (value is List) { + return value.map(sortedJson).toList(); + } + return value; +} diff --git a/packages/datadog_flags/lib/src/rum_flag_evaluation_reporter.dart b/packages/datadog_flags/lib/src/rum_flag_evaluation_reporter.dart new file mode 100644 index 000000000..1ebb3efe4 --- /dev/null +++ b/packages/datadog_flags/lib/src/rum_flag_evaluation_reporter.dart @@ -0,0 +1,28 @@ +// 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_flutter_plugin/datadog_flutter_plugin.dart'; + +abstract class RumFlagEvaluationReporter { + void report(String flagKey, Object value); +} + +class DatadogRumFlagEvaluationReporter implements RumFlagEvaluationReporter { + final DatadogRum? rum; + final bool enabled; + + DatadogRumFlagEvaluationReporter({ + required this.rum, + required this.enabled, + }); + + @override + void report(String flagKey, Object value) { + if (!enabled) { + return; + } + rum?.addFeatureFlagEvaluation(flagKey, value); + } +} diff --git a/packages/datadog_flags/pubspec.yaml b/packages/datadog_flags/pubspec.yaml new file mode 100644 index 000000000..f7bbb04f2 --- /dev/null +++ b/packages/datadog_flags/pubspec.yaml @@ -0,0 +1,26 @@ +name: datadog_flags +description: Native Dart/Flutter client for Datadog feature flag evaluation. +version: 0.0.1 +repository: https://github.com/DataDog/dd-sdk-flutter +homepage: https://datadoghq.com + +environment: + sdk: ">=3.6.0 <4.0.0" + flutter: ">=3.19.0" + +dependencies: + flutter: + sdk: flutter + datadog_flutter_plugin: ^3.3.0 + http: ^1.0.0 + shared_preferences: ^2.3.0 + uuid: ^4.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.1 + +flutter: + assets: + - test/fixtures/precomputed/cases/ diff --git a/packages/datadog_flags/test/datadog_flags_test.dart b/packages/datadog_flags/test/datadog_flags_test.dart new file mode 100644 index 000000000..b3486ced3 --- /dev/null +++ b/packages/datadog_flags/test/datadog_flags_test.dart @@ -0,0 +1,585 @@ +// 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/src/evaluation_aggregator.dart'; +import 'package:datadog_flags/src/exposure_logger.dart'; +import 'package:datadog_flags/src/flag_assignments_fetcher.dart'; +import 'package:datadog_flags/src/flags_repository.dart'; +import 'package:datadog_flags/src/rum_flag_evaluation_reporter.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'; + +void main() { + late List requests; + late DateTime now; + + DatadogFlagsContext datadogContext({DatadogSite site = DatadogSite.us1}) { + return DatadogFlagsContext( + clientToken: 'client-token', + env: 'staging', + site: site, + service: 'flutter-example', + version: '1.2.3', + applicationId: 'rum-app-id', + sdkVersion: '9.8.7', + ); + } + + setUp(() async { + requests = []; + now = DateTime.fromMillisecondsSinceEpoch(1234567890000); + await DatadogFlags.disable(); + }); + + tearDown(() async { + await DatadogFlags.disable(); + }); + + http.Client clientWithResponse(Object body, {int statusCode = 200}) { + return MockClient((request) async { + requests.add(request); + return http.Response(jsonEncode(body), statusCode); + }); + } + + Map assignmentsResponse({ + bool doLog = true, + String booleanVariationKey = 'enabled', + bool booleanValue = true, + }) { + return { + 'data': { + 'attributes': { + 'flags': { + 'show-paywall': { + 'allocationKey': 'allocation-a', + 'variationKey': booleanVariationKey, + 'variationType': 'boolean', + 'variationValue': booleanValue, + 'reason': 'TARGETING_MATCH', + 'doLog': doLog, + }, + 'theme': { + 'allocationKey': 'allocation-b', + 'variationKey': 'dark', + 'variationType': 'string', + 'variationValue': 'dark', + 'reason': 'TARGETING_MATCH', + 'doLog': true, + }, + 'max-items': { + 'allocationKey': 'allocation-c', + 'variationKey': 'three', + 'variationType': 'integer', + 'variationValue': 3, + 'reason': 'TARGETING_MATCH', + 'doLog': true, + }, + 'ratio': { + 'allocationKey': 'allocation-d', + 'variationKey': 'half', + 'variationType': 'float', + 'variationValue': 0.5, + 'reason': 'TARGETING_MATCH', + 'doLog': true, + }, + 'config': { + 'allocationKey': 'allocation-e', + 'variationKey': 'object', + 'variationType': 'object', + 'variationValue': { + 'enabled': true, + 'labels': ['a', 'b'], + }, + 'reason': 'TARGETING_MATCH', + 'doLog': true, + }, + 'bad': { + 'allocationKey': 'allocation-f', + 'variationKey': 'bad', + 'variationType': 'unsupported', + 'variationValue': 'bad', + 'reason': 'TARGETING_MATCH', + 'doLog': true, + }, + }, + }, + }, + }; + } + + Future createClient({ + required http.Client httpClient, + InMemoryDatadogFlagsStore? store, + DatadogSite site = DatadogSite.us1, + bool trackExposures = true, + bool trackEvaluations = true, + }) async { + await DatadogFlags.enable( + configuration: DatadogFlagsConfiguration( + datadogContext: datadogContext(site: site), + store: store ?? InMemoryDatadogFlagsStore(), + httpClient: httpClient, + dateProvider: () => now, + evaluationFlushInterval: const Duration(seconds: 1), + trackExposures: trackExposures, + trackEvaluations: trackEvaluations, + ), + ); + return DatadogFlagsClient.create(); + } + + List exposureRequests() { + return requests + .where((request) => request.url.path == '/api/v2/exposures') + .toList(); + } + + List evaluationRequests() { + return requests + .where((request) => request.url.path == '/api/v2/flagevaluation') + .toList(); + } + + test('enable creates the default shared client', () async { + await DatadogFlags.enable( + configuration: DatadogFlagsConfiguration( + datadogContext: datadogContext(), + store: InMemoryDatadogFlagsStore(), + httpClient: clientWithResponse(assignmentsResponse()), + dateProvider: () => now, + ), + ); + + final shared = DatadogFlags.sharedClient(); + + expect(shared.name, DatadogFlagsClient.defaultName); + }); + + test('fetches precomputed assignments using the iOS request shape', () async { + final client = await createClient( + httpClient: clientWithResponse(assignmentsResponse()), + ); + + await client.setEvaluationContext(const DatadogFlagsEvaluationContext( + targetingKey: 'user-123', + attributes: { + 'plan': 'pro', + 'seat_count': 3, + }, + )); + + expect(requests, hasLength(1)); + expect( + requests.single.url.toString(), + 'https://preview.ff-cdn.datadoghq.com/precompute-assignments', + ); + expect(requests.single.headers['Content-Type'], 'application/vnd.api+json'); + expect(requests.single.headers['dd-client-token'], 'client-token'); + expect(requests.single.headers['dd-application-id'], 'rum-app-id'); + + final body = jsonDecode(requests.single.body) as Map; + final attributes = ((body['data'] as Map)['attributes'] + as Map); + expect(attributes['env'], {'name': 'staging', 'dd_env': 'staging'}); + expect(attributes['source'], { + 'sdk_name': 'dd-sdk-flutter', + 'sdk_version': '9.8.7', + }); + expect(attributes['subject'], { + 'targeting_key': 'user-123', + 'targeting_attributes': {'plan': 'pro', 'seat_count': 3}, + }); + }); + + test('uses Datadog site endpoints and falls back to US1 for gov flags', () { + expect( + datadogContext(site: DatadogSite.us3).flagsEndpoint().toString(), + 'https://preview.ff-cdn.us3.datadoghq.com', + ); + expect( + datadogContext(site: DatadogSite.us5).flagsEndpoint().toString(), + 'https://preview.ff-cdn.us5.datadoghq.com', + ); + expect( + datadogContext(site: DatadogSite.eu1).flagsEndpoint().toString(), + 'https://preview.ff-cdn.datadoghq.eu', + ); + expect( + datadogContext(site: DatadogSite.ap1).flagsEndpoint().toString(), + 'https://preview.ff-cdn.ap1.datadoghq.com', + ); + expect( + datadogContext(site: DatadogSite.ap2).flagsEndpoint().toString(), + 'https://preview.ff-cdn.ap2.datadoghq.com', + ); + expect( + datadogContext(site: DatadogSite.us1Fed).flagsEndpoint().toString(), + 'https://preview.ff-cdn.datadoghq.com', + ); + }); + + test('returns typed values and drops unknown variation types', () async { + final client = await createClient( + httpClient: clientWithResponse(assignmentsResponse()), + ); + await client.setEvaluationContext( + const DatadogFlagsEvaluationContext(targetingKey: 'user-123'), + ); + + expect( + client.getBooleanValue(key: 'show-paywall', defaultValue: false), + isTrue, + ); + expect(client.getStringValue(key: 'theme', defaultValue: 'light'), 'dark'); + expect(client.getIntegerValue(key: 'max-items', defaultValue: 1), 3); + expect(client.getDoubleValue(key: 'ratio', defaultValue: 1), 0.5); + expect(client.getObjectValue(key: 'config', defaultValue: null), { + 'enabled': true, + 'labels': ['a', 'b'], + }); + + final missing = client.getStringDetails( + key: 'bad', + defaultValue: 'fallback', + ); + expect(missing.value, 'fallback'); + expect(missing.error, FlagEvaluationError.flagNotFound); + }); + + test('throws network errors for unsuccessful precompute responses', () async { + final client = await createClient( + httpClient: clientWithResponse({'error': 'nope'}, statusCode: 500), + trackExposures: false, + trackEvaluations: false, + ); + + await expectLater( + client.setEvaluationContext( + const DatadogFlagsEvaluationContext(targetingKey: 'user-123'), + ), + throwsA(isA().having( + (error) => error.type, + 'type', + FlagsErrorType.networkError, + )), + ); + }); + + test('throws invalid response errors for malformed precompute payloads', + () async { + final client = await createClient( + httpClient: clientWithResponse({'data': null}), + trackExposures: false, + trackEvaluations: false, + ); + + await expectLater( + client.setEvaluationContext( + const DatadogFlagsEvaluationContext(targetingKey: 'user-123'), + ), + throwsA(isA().having( + (error) => error.type, + 'type', + FlagsErrorType.invalidResponse, + )), + ); + }); + + test('persists the last known assignments for a later client', () async { + final store = InMemoryDatadogFlagsStore(); + final client = await createClient( + httpClient: clientWithResponse(assignmentsResponse()), + store: store, + trackExposures: false, + trackEvaluations: false, + ); + await client.setEvaluationContext( + const DatadogFlagsEvaluationContext(targetingKey: 'user-123'), + ); + await DatadogFlags.disable(); + + final restored = await createClient( + httpClient: clientWithResponse(assignmentsResponse()), + store: store, + trackExposures: false, + trackEvaluations: false, + ); + + expect( + restored.getBooleanValue(key: 'show-paywall', defaultValue: false), + isTrue, + ); + }); + + test('keeps the latest context when fetches resolve out of order', () async { + final responseCompleters = >[]; + final client = await createClient( + trackExposures: false, + trackEvaluations: false, + httpClient: MockClient((request) { + requests.add(request); + final completer = Completer(); + responseCompleters.add(completer); + return completer.future; + }), + ); + + final first = client.setEvaluationContext( + const DatadogFlagsEvaluationContext(targetingKey: 'user-first'), + ); + final second = client.setEvaluationContext( + const DatadogFlagsEvaluationContext(targetingKey: 'user-second'), + ); + await Future.delayed(Duration.zero); + + expect(responseCompleters, hasLength(2)); + responseCompleters[1].complete(http.Response( + jsonEncode(assignmentsResponse( + booleanVariationKey: 'second', + booleanValue: false, + )), + 200, + )); + await second; + + responseCompleters[0].complete(http.Response( + jsonEncode(assignmentsResponse( + booleanVariationKey: 'first', + booleanValue: true, + )), + 200, + )); + await first; + + final details = client.getBooleanDetails( + key: 'show-paywall', + defaultValue: true, + ); + expect(details.value, isFalse); + expect(details.variant, 'second'); + }); + + test('reports only successful typed evaluations to RUM', () async { + final datadogContextValue = datadogContext(); + final config = DatadogFlagsConfiguration( + datadogContext: datadogContextValue, + store: InMemoryDatadogFlagsStore(), + httpClient: clientWithResponse(assignmentsResponse()), + dateProvider: () => now, + trackExposures: false, + trackEvaluations: false, + ); + final fetcher = FlagAssignmentsFetcher( + datadogContext: datadogContextValue, + configuration: config, + httpClient: config.httpClient!, + ); + final repository = FlagsRepository( + clientName: 'rum-test', + fetcher: fetcher, + store: config.store!, + dateProvider: () => now, + ); + await repository.restore(); + final fakeRum = FakeRumFlagEvaluationReporter(); + final client = DatadogFlagsClient( + name: 'rum-test', + repository: repository, + exposureLogger: ExposureLogger( + datadogContext: datadogContextValue, + configuration: config, + httpClient: config.httpClient!, + ), + evaluationAggregator: EvaluationAggregator( + datadogContext: datadogContextValue, + configuration: config, + httpClient: config.httpClient!, + ), + rumFlagEvaluationReporter: fakeRum, + ); + + client.getBooleanValue(key: 'show-paywall', defaultValue: false); + await client.setEvaluationContext( + const DatadogFlagsEvaluationContext(targetingKey: 'user-123'), + ); + client.getBooleanValue(key: 'show-paywall', defaultValue: false); + client.getIntegerValue(key: 'show-paywall', defaultValue: 0); + client.getBooleanValue(key: 'missing', defaultValue: false); + + expect(fakeRum.calls, [ + const RumCall('show-paywall', true), + ]); + }); + + test('reports provider readiness, not-found, and type mismatch details', + () async { + final client = await createClient( + httpClient: clientWithResponse(assignmentsResponse()), + ); + + final notReady = client.getBooleanDetails( + key: 'show-paywall', + defaultValue: false, + ); + expect(notReady.error, FlagEvaluationError.providerNotReady); + + await client.setEvaluationContext( + const DatadogFlagsEvaluationContext(targetingKey: 'user-123'), + ); + + final missing = client.getBooleanDetails( + key: 'missing', + defaultValue: false, + ); + expect(missing.error, FlagEvaluationError.flagNotFound); + + final mismatch = client.getIntegerDetails( + key: 'show-paywall', + defaultValue: 7, + ); + expect(mismatch.value, 7); + expect(mismatch.error, FlagEvaluationError.typeMismatch); + }); + + test('counts exposure emissions at the HTTP boundary', () async { + final client = await createClient( + httpClient: clientWithResponse(assignmentsResponse()), + ); + await client.setEvaluationContext( + const DatadogFlagsEvaluationContext(targetingKey: 'user-123'), + ); + + client.getBooleanValue(key: 'show-paywall', defaultValue: false); + await Future.delayed(Duration.zero); + client.getBooleanValue(key: 'show-paywall', defaultValue: false); + client.getIntegerValue(key: 'show-paywall', defaultValue: 0); + client.getBooleanValue(key: 'missing', defaultValue: false); + await Future.delayed(Duration.zero); + + expect(exposureRequests(), hasLength(1)); + final exposure = jsonDecode(exposureRequests().single.body); + expect(exposure['flag'], {'key': 'show-paywall'}); + expect(exposure['allocation'], {'key': 'allocation-a'}); + expect(exposure['variant'], {'key': 'enabled'}); + expect(exposure['subject'], { + 'id': 'user-123', + 'attributes': {}, + }); + }); + + test('does not emit exposures when doLog is false', () async { + final client = await createClient( + httpClient: clientWithResponse(assignmentsResponse(doLog: false)), + ); + await client.setEvaluationContext( + const DatadogFlagsEvaluationContext(targetingKey: 'user-123'), + ); + + client.getBooleanValue(key: 'show-paywall', defaultValue: false); + await Future.delayed(Duration.zero); + + expect(exposureRequests(), isEmpty); + }); + + test('flushes aggregated evaluation metrics with success and error payloads', + () async { + final client = await createClient( + httpClient: clientWithResponse(assignmentsResponse()), + ); + + client.getBooleanValue(key: 'show-paywall', defaultValue: false); + await client.setEvaluationContext( + const DatadogFlagsEvaluationContext( + targetingKey: 'user-123', + attributes: {'plan': 'pro'}, + ), + ); + client.getBooleanValue(key: 'show-paywall', defaultValue: false); + client.getBooleanValue(key: 'show-paywall', defaultValue: false); + client.getIntegerValue(key: 'show-paywall', defaultValue: 0); + client.getBooleanValue(key: 'missing', defaultValue: false); + + await client.flush(); + + expect(evaluationRequests(), hasLength(1)); + final batches = evaluationRequests().map((request) { + return jsonDecode(request.body) as Map; + }).toList(); + final evaluations = batches + .expand((batch) { + return batch['flagEvaluations'] as List; + }) + .cast>() + .toList(); + + final success = evaluations.singleWhere((evaluation) { + return (evaluation['flag'] as Map)['key'] == + 'show-paywall' && + evaluation['error'] == null; + }); + expect(success['evaluation_count'], 2); + expect(success['variant'], {'key': 'enabled'}); + expect(success['allocation'], {'key': 'allocation-a'}); + expect(success['runtime_default_used'], isNull); + expect(success['context'], { + 'evaluation': {'plan': 'pro'}, + 'dd': null, + }); + + final providerNotReady = evaluations.singleWhere((evaluation) { + return ((evaluation['error'] as Map?)?['message']) == + 'PROVIDER_NOT_READY'; + }); + expect(providerNotReady['runtime_default_used'], isTrue); + expect(providerNotReady['variant'], isNull); + expect(providerNotReady['allocation'], isNull); + + final typeMismatch = evaluations.singleWhere((evaluation) { + return ((evaluation['error'] as Map?)?['message']) == + 'TYPE_MISMATCH'; + }); + expect(typeMismatch['runtime_default_used'], isTrue); + + final flagNotFound = evaluations.singleWhere((evaluation) { + return ((evaluation['error'] as Map?)?['message']) == + 'FLAG_NOT_FOUND'; + }); + expect(flagNotFound['runtime_default_used'], isTrue); + }); +} + +class FakeRumFlagEvaluationReporter implements RumFlagEvaluationReporter { + final calls = []; + + @override + void report(String flagKey, Object value) { + calls.add(RumCall(flagKey, value)); + } +} + +class RumCall { + final String flagKey; + final Object value; + + const RumCall(this.flagKey, this.value); + + @override + bool operator ==(Object other) { + return other is RumCall && other.flagKey == flagKey && other.value == value; + } + + @override + int get hashCode => Object.hash(flagKey, value); + + @override + String toString() => 'RumCall($flagKey, $value)'; +} diff --git a/packages/datadog_flags/test/fixtures/precomputed/cases/all-types-success.json b/packages/datadog_flags/test/fixtures/precomputed/cases/all-types-success.json new file mode 100644 index 000000000..5263a6422 --- /dev/null +++ b/packages/datadog_flags/test/fixtures/precomputed/cases/all-types-success.json @@ -0,0 +1,133 @@ +{ + "name": "all-types-success", + "description": "Successful precomputed assignments for all supported Flutter value types.", + "context": { + "targetingKey": "precomputed-user", + "attributes": { + "plan": "pro", + "platform": "flutter" + } + }, + "response": { + "data": { + "attributes": { + "flags": { + "flutter.fixture.enabled": { + "allocationKey": "allocation-success", + "variationKey": "enabled", + "variationType": "boolean", + "variationValue": true, + "reason": "TARGETING_MATCH", + "doLog": true + }, + "flutter.fixture.title": { + "allocationKey": "allocation-success", + "variationKey": "copy-a", + "variationType": "string", + "variationValue": "Datadog Flags", + "reason": "TARGETING_MATCH", + "doLog": true + }, + "flutter.fixture.limit": { + "allocationKey": "allocation-success", + "variationKey": "limit-five", + "variationType": "integer", + "variationValue": 5, + "reason": "TARGETING_MATCH", + "doLog": true + }, + "flutter.fixture.ratio": { + "allocationKey": "allocation-success", + "variationKey": "half", + "variationType": "float", + "variationValue": 0.5, + "reason": "TARGETING_MATCH", + "doLog": true + }, + "flutter.fixture.config": { + "allocationKey": "allocation-success", + "variationKey": "object-a", + "variationType": "object", + "variationValue": { + "showBanner": true, + "colors": [ + "blue", + "green" + ] + }, + "reason": "TARGETING_MATCH", + "doLog": true + } + } + } + } + }, + "evaluations": [ + { + "flag": "flutter.fixture.enabled", + "variationType": "boolean", + "defaultValue": false, + "result": { + "value": true, + "variant": "enabled", + "reason": "TARGETING_MATCH", + "error": null + } + }, + { + "flag": "flutter.fixture.title", + "variationType": "string", + "defaultValue": "Fallback title", + "result": { + "value": "Datadog Flags", + "variant": "copy-a", + "reason": "TARGETING_MATCH", + "error": null + } + }, + { + "flag": "flutter.fixture.limit", + "variationType": "integer", + "defaultValue": 0, + "result": { + "value": 5, + "variant": "limit-five", + "reason": "TARGETING_MATCH", + "error": null + } + }, + { + "flag": "flutter.fixture.ratio", + "variationType": "float", + "defaultValue": 0.0, + "result": { + "value": 0.5, + "variant": "half", + "reason": "TARGETING_MATCH", + "error": null + } + }, + { + "flag": "flutter.fixture.config", + "variationType": "object", + "defaultValue": {}, + "result": { + "value": { + "showBanner": true, + "colors": [ + "blue", + "green" + ] + }, + "variant": "object-a", + "reason": "TARGETING_MATCH", + "error": null + } + } + ], + "expectedEmissions": { + "exposures": 5, + "flagevaluationRequests": 1, + "flagevaluationEvents": 5 + } +} diff --git a/packages/datadog_flags/test/fixtures/precomputed/cases/defaults-and-emission-gates.json b/packages/datadog_flags/test/fixtures/precomputed/cases/defaults-and-emission-gates.json new file mode 100644 index 000000000..a1838ee7e --- /dev/null +++ b/packages/datadog_flags/test/fixtures/precomputed/cases/defaults-and-emission-gates.json @@ -0,0 +1,94 @@ +{ + "name": "defaults-and-emission-gates", + "description": "Precomputed assignments that validate exposure gates, unknown variation isolation, and default/error flagevaluation payloads.", + "context": { + "targetingKey": "precomputed-user", + "attributes": { + "plan": "free", + "platform": "flutter" + } + }, + "response": { + "data": { + "attributes": { + "flags": { + "flutter.fixture.silent": { + "allocationKey": "allocation-silent", + "variationKey": "silent-on", + "variationType": "boolean", + "variationValue": true, + "reason": "TARGETING_MATCH", + "doLog": false + }, + "flutter.fixture.string": { + "allocationKey": "allocation-string", + "variationKey": "copy-b", + "variationType": "string", + "variationValue": "Actual string", + "reason": "TARGETING_MATCH", + "doLog": true + }, + "flutter.fixture.unknown": { + "allocationKey": "allocation-unknown", + "variationKey": "unknown-a", + "variationType": "unsupported", + "variationValue": "Ignored", + "reason": "TARGETING_MATCH", + "doLog": true + } + } + } + } + }, + "evaluations": [ + { + "flag": "flutter.fixture.silent", + "variationType": "boolean", + "defaultValue": false, + "result": { + "value": true, + "variant": "silent-on", + "reason": "TARGETING_MATCH", + "error": null + } + }, + { + "flag": "flutter.fixture.string", + "variationType": "boolean", + "defaultValue": false, + "result": { + "value": false, + "variant": null, + "reason": null, + "error": "typeMismatch" + } + }, + { + "flag": "flutter.fixture.unknown", + "variationType": "boolean", + "defaultValue": false, + "result": { + "value": false, + "variant": null, + "reason": null, + "error": "flagNotFound" + } + }, + { + "flag": "flutter.fixture.missing", + "variationType": "string", + "defaultValue": "fallback", + "result": { + "value": "fallback", + "variant": null, + "reason": null, + "error": "flagNotFound" + } + } + ], + "expectedEmissions": { + "exposures": 0, + "flagevaluationRequests": 1, + "flagevaluationEvents": 4 + } +} diff --git a/packages/datadog_flags/test/precomputed_fixture_test.dart b/packages/datadog_flags/test/precomputed_fixture_test.dart new file mode 100644 index 000000000..537fe3871 --- /dev/null +++ b/packages/datadog_flags/test/precomputed_fixture_test.dart @@ -0,0 +1,197 @@ +// 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_flags/datadog_flags.dart'; +import 'package:datadog_flutter_plugin/datadog_flutter_plugin.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const fixtureAssets = [ + 'test/fixtures/precomputed/cases/all-types-success.json', + 'test/fixtures/precomputed/cases/defaults-and-emission-gates.json', + ]; + + setUp(() async { + await DatadogFlags.disable(); + }); + + tearDown(() async { + await DatadogFlags.disable(); + }); + + for (final fixtureAsset in fixtureAssets) { + test( + 'precomputed fixture ${fixtureAsset.split('/').last}', + () async { + final fixture = jsonDecode(await rootBundle.loadString(fixtureAsset)) + as Map; + final requests = []; + final httpClient = MockClient((request) async { + requests.add(request); + if (request.url.path == '/precompute-assignments') { + return http.Response(jsonEncode(fixture['response']), 200); + } + if (request.url.path == '/api/v2/exposures' || + request.url.path == '/api/v2/flagevaluation') { + return http.Response('{"ok":true}', 200); + } + return http.Response('{"error":"unexpected request"}', 404); + }); + + await DatadogFlags.enable( + configuration: DatadogFlagsConfiguration( + datadogContext: const DatadogFlagsContext( + clientToken: 'client-token', + env: 'staging', + site: DatadogSite.us1, + service: 'fixture-test', + version: '1.0.0', + applicationId: 'rum-app-id', + sdkVersion: '9.8.7', + ), + store: InMemoryDatadogFlagsStore(), + httpClient: httpClient, + dateProvider: () => DateTime.fromMillisecondsSinceEpoch(1234567890), + evaluationFlushInterval: const Duration(seconds: 1), + ), + ); + try { + final client = DatadogFlags.sharedClient(); + + final context = DatadogFlagsEvaluationContext.fromJson( + fixture['context'] as Map, + ); + await client.setEvaluationContext(context); + + final evaluations = (fixture['evaluations'] as List) + .cast>(); + for (final evaluation in evaluations) { + final details = _evaluate(client, evaluation); + final result = evaluation['result'] as Map; + expect(details.value, result['value']); + expect(details.variant, result['variant']); + expect(details.reason, result['reason']); + expect(details.error?.name, result['error']); + } + + await Future.delayed(Duration.zero); + await client.flush(); + + final exposureRequests = requests + .where((request) => request.url.path == '/api/v2/exposures') + .toList(); + final evaluationRequests = requests + .where((request) => request.url.path == '/api/v2/flagevaluation') + .toList(); + final expectedEmissions = + fixture['expectedEmissions'] as Map; + + expect( + exposureRequests, + hasLength(expectedEmissions['exposures'] as int), + ); + expect( + evaluationRequests, + hasLength(expectedEmissions['flagevaluationRequests'] as int), + ); + + final evaluationEvents = evaluationRequests + .expand((request) { + final body = jsonDecode(request.body) as Map; + return body['flagEvaluations'] as List; + }) + .cast>() + .toList(); + expect( + evaluationEvents, + hasLength(expectedEmissions['flagevaluationEvents'] as int), + ); + _assertEvaluationPayloads(evaluationEvents, evaluations); + } finally { + await DatadogFlags.disable(); + } + }, + skip: kIsWeb ? 'Fixture asset-file loading is validated on VM.' : false, + ); + } +} + +FlagDetails _evaluate( + DatadogFlagsClient client, + Map evaluation, +) { + final flag = evaluation['flag'] as String; + final defaultValue = evaluation['defaultValue']; + switch (evaluation['variationType'] as String) { + case 'boolean': + return client.getBooleanDetails( + key: flag, + defaultValue: defaultValue as bool, + ); + case 'string': + return client.getStringDetails( + key: flag, + defaultValue: defaultValue as String, + ); + case 'integer': + return client.getIntegerDetails( + key: flag, + defaultValue: defaultValue as int, + ); + case 'float': + return client.getDoubleDetails( + key: flag, + defaultValue: (defaultValue as num).toDouble(), + ); + case 'object': + return client.getObjectDetails(key: flag, defaultValue: defaultValue); + default: + throw StateError('Unknown fixture variation type $evaluation'); + } +} + +void _assertEvaluationPayloads( + List> actual, + List> expected, +) { + for (final expectedEvaluation in expected) { + final flag = expectedEvaluation['flag'] as String; + final result = expectedEvaluation['result'] as Map; + final event = actual.singleWhere((candidate) { + return (candidate['flag'] as Map)['key'] == flag; + }); + + expect(event['evaluation_count'], 1); + if (result['error'] == null) { + expect(event['runtime_default_used'], isNull); + expect( + (event['variant'] as Map)['key'], result['variant']); + } else { + expect(event['runtime_default_used'], isTrue); + expect(event['variant'], isNull); + expect( + (event['error'] as Map)['message'], + _wireError(result['error'] as String), + ); + } + } +} + +String _wireError(String error) { + return switch (error) { + 'flagNotFound' => 'FLAG_NOT_FOUND', + 'providerNotReady' => 'PROVIDER_NOT_READY', + 'typeMismatch' => 'TYPE_MISMATCH', + _ => throw StateError('Unknown fixture error $error'), + }; +} diff --git a/packages/datadog_flutter_plugin/lib/datadog_flutter_plugin.dart b/packages/datadog_flutter_plugin/lib/datadog_flutter_plugin.dart index e4a9241e2..b49ee9ad1 100644 --- a/packages/datadog_flutter_plugin/lib/datadog_flutter_plugin.dart +++ b/packages/datadog_flutter_plugin/lib/datadog_flutter_plugin.dart @@ -175,6 +175,8 @@ class DatadogSdk { DatadogConfiguration configuration, TrackingConsent trackingConsent, ) async { + _configuration = configuration; + // First set our SDK verbosity. We can assume WidgetsFlutterBinding has been initialized at this point await _platform.setSdkVerbosity(internalLogger.sdkVerbosity); diff --git a/packages/datadog_flutter_plugin/test/datadog_sdk_test.dart b/packages/datadog_flutter_plugin/test/datadog_sdk_test.dart index 2d97344fc..e149fe3d0 100644 --- a/packages/datadog_flutter_plugin/test/datadog_sdk_test.dart +++ b/packages/datadog_flutter_plugin/test/datadog_sdk_test.dart @@ -148,6 +148,18 @@ void main() { ); }); + test('initialize stores configuration', () async { + final configuration = DatadogConfiguration( + clientToken: 'clientToken', + env: 'env', + site: DatadogSite.us1, + ); + + await datadogSdk.initialize(configuration, TrackingConsent.granted); + + expect(datadogSdk.configuration, same(configuration)); + }); + test('encode base configuration', () { final configuration = DatadogConfiguration( clientToken: 'fake-client-token', From 571fd38e2a02b636a33f93b3e9c20a92d45e622f Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 20:34:13 -0400 Subject: [PATCH 02/16] Add live flagging dogfood counters --- .../lib/flags/flags_demo_runtime.dart | 117 +++++++++--- .../lib/flags/flags_request_counter.dart | 13 ++ .../lib/flags/forwarding_flags_counter.dart | 68 +++++++ .../lib/flags/local_flags_collector_io.dart | 12 +- .../lib/flags/local_flags_collector_stub.dart | 12 +- .../lib/flags/local_flags_payloads.dart | 8 + examples/simple_example/lib/main.dart | 87 +++++++-- .../lib/screens/flags_screen.dart | 168 +++++++++++++----- 8 files changed, 400 insertions(+), 85 deletions(-) create mode 100644 examples/simple_example/lib/flags/flags_request_counter.dart create mode 100644 examples/simple_example/lib/flags/forwarding_flags_counter.dart diff --git a/examples/simple_example/lib/flags/flags_demo_runtime.dart b/examples/simple_example/lib/flags/flags_demo_runtime.dart index 58c365e04..bc694bb11 100644 --- a/examples/simple_example/lib/flags/flags_demo_runtime.dart +++ b/examples/simple_example/lib/flags/flags_demo_runtime.dart @@ -6,6 +6,8 @@ import 'package:datadog_flags/datadog_flags.dart'; import 'package:datadog_flutter_plugin/datadog_flutter_plugin.dart'; +import 'flags_request_counter.dart'; +import 'forwarding_flags_counter.dart'; import 'local_flags_collector.dart'; const _externalFlagsEndpoint = String.fromEnvironment('FLAGS_ENDPOINT'); @@ -17,19 +19,28 @@ const _externalEvaluationEndpoint = class FlagsDemoRuntime { final String mode; final LocalFlagsCollector? collector; + final FlagsRequestCounter? counter; final DatadogFlagsConfiguration configuration; const FlagsDemoRuntime._({ required this.mode, required this.collector, + required this.counter, required this.configuration, }); + bool get usesFixtureFlags => mode == 'local' || mode == 'fixture'; + Future stop() async { - await collector?.stop(); + await counter?.stop(); } - static Future create() async { + static Future create({ + String? clientToken, + String? env, + String? siteName, + String? applicationId, + }) async { const mode = String.fromEnvironment('FLAGS_MODE', defaultValue: 'local'); final externalFlagsEndpoint = _uriFromEnvironment(_externalFlagsEndpoint); final externalExposureEndpoint = @@ -37,43 +48,105 @@ class FlagsDemoRuntime { final externalEvaluationEndpoint = _uriFromEnvironment(_externalEvaluationEndpoint); + final useFixture = mode == 'local' || mode == 'fixture'; + final useForwardingCounter = !useFixture; + final useDatad0g = siteName == 'datad0g.com'; + LocalFlagsCollector? collector; - if (mode == 'local' && externalFlagsEndpoint == null) { + if (useFixture && externalFlagsEndpoint == null) { collector = await LocalFlagsCollector.start(); } + final forwardingCounter = + useForwardingCounter ? ForwardingFlagsCounter.create() : null; + final counter = collector ?? forwardingCounter; return FlagsDemoRuntime._( mode: mode, collector: collector, + counter: counter, configuration: DatadogFlagsConfiguration( - customFlagsEndpoint: - externalFlagsEndpoint ?? collector?.precomputeEndpoint, - customExposureEndpoint: - externalExposureEndpoint ?? collector?.exposureEndpoint, - customEvaluationEndpoint: - externalEvaluationEndpoint ?? collector?.evaluationEndpoint, - httpClient: collector?.httpClient, - store: mode == 'local' ? InMemoryDatadogFlagsStore() : null, - datadogContext: mode == 'local' - ? const DatadogFlagsContext( - clientToken: 'local-client-token', - env: 'local', - site: DatadogSite.us1, - service: 'simple-example', - version: '1.0.0', - applicationId: 'local-application-id', - sdkVersion: '3.3.0', - ) - : null, + customFlagsEndpoint: externalFlagsEndpoint ?? + collector?.precomputeEndpoint ?? + (useDatad0g + ? Uri.https( + 'preview.ff-cdn.datad0g.com', + '/precompute-assignments', + {'dd_env': env ?? 'dev'}, + ) + : null), + customExposureEndpoint: externalExposureEndpoint ?? + collector?.exposureEndpoint ?? + (useDatad0g + ? Uri.parse( + 'https://browser-intake-datad0g.com/api/v2/exposures?ddsource=flutter', + ) + : null), + customEvaluationEndpoint: externalEvaluationEndpoint ?? + collector?.evaluationEndpoint ?? + (useDatad0g + ? Uri.parse( + 'https://browser-intake-datad0g.com/api/v2/flagevaluation?ddsource=flutter', + ) + : null), + httpClient: collector?.httpClient ?? forwardingCounter?.httpClient, + store: useFixture ? InMemoryDatadogFlagsStore() : null, + datadogContext: _datadogContext( + useFixture: useFixture, + useDatad0g: useDatad0g, + clientToken: clientToken, + env: env, + applicationId: applicationId, + ), evaluationFlushInterval: const Duration(seconds: 1), ), ); } } +DatadogFlagsContext? _datadogContext({ + required bool useFixture, + required bool useDatad0g, + required String? clientToken, + required String? env, + required String? applicationId, +}) { + if (useFixture) { + return const DatadogFlagsContext( + clientToken: 'local-client-token', + env: 'local', + site: DatadogSite.us1, + service: 'simple-example', + version: '1.0.0', + applicationId: 'local-application-id', + sdkVersion: '3.3.0', + ); + } + + if (!useDatad0g) { + return null; + } + + return DatadogFlagsContext( + clientToken: clientToken ?? '', + env: env ?? 'staging', + site: DatadogSite.us1, + service: 'simple-example', + version: '1.0.0', + applicationId: _emptyToNull(applicationId), + sdkVersion: DatadogSdk.sdkVersion, + ); +} + Uri? _uriFromEnvironment(String value) { if (value.isEmpty) { return null; } return Uri.parse(value); } + +String? _emptyToNull(String? value) { + if (value == null || value.isEmpty) { + return null; + } + return value; +} diff --git a/examples/simple_example/lib/flags/flags_request_counter.dart b/examples/simple_example/lib/flags/flags_request_counter.dart new file mode 100644 index 000000000..ed6ca0867 --- /dev/null +++ b/examples/simple_example/lib/flags/flags_request_counter.dart @@ -0,0 +1,13 @@ +// 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. + +abstract interface class FlagsRequestCounter { + int get precomputeRequestCount; + int get exposureCount; + int get evaluationRequestCount; + int get evaluationEventCount; + + Future stop(); +} diff --git a/examples/simple_example/lib/flags/forwarding_flags_counter.dart b/examples/simple_example/lib/flags/forwarding_flags_counter.dart new file mode 100644 index 000000000..41b67bf07 --- /dev/null +++ b/examples/simple_example/lib/flags/forwarding_flags_counter.dart @@ -0,0 +1,68 @@ +// 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:http/http.dart' as http; + +import 'flags_request_counter.dart'; +import 'local_flags_payloads.dart'; + +class ForwardingFlagsCounter implements FlagsRequestCounter { + final CountingFlagsHttpClient httpClient; + + ForwardingFlagsCounter._(this.httpClient); + + factory ForwardingFlagsCounter.create() { + return ForwardingFlagsCounter._(CountingFlagsHttpClient(http.Client())); + } + + @override + int get precomputeRequestCount => httpClient.precomputeRequestCount; + + @override + int get exposureCount => httpClient.exposureCount; + + @override + int get evaluationRequestCount => httpClient.evaluationRequestCount; + + @override + int get evaluationEventCount => httpClient.evaluationEventCount; + + @override + Future stop() async { + httpClient.close(); + } +} + +class CountingFlagsHttpClient extends http.BaseClient { + final http.Client _inner; + + int precomputeRequestCount = 0; + int exposureCount = 0; + int evaluationRequestCount = 0; + int evaluationEventCount = 0; + + CountingFlagsHttpClient(this._inner); + + @override + Future send(http.BaseRequest request) { + final body = request is http.Request ? request.body : ''; + final path = request.url.path; + if (path == '/precompute-assignments') { + precomputeRequestCount += 1; + } else if (path == '/api/v2/exposures') { + exposureCount += countExposureBody(body); + } else if (path == '/api/v2/flagevaluation') { + evaluationRequestCount += 1; + evaluationEventCount += tryCountEvaluationEvents(body); + } + return _inner.send(request); + } + + @override + void close() { + _inner.close(); + super.close(); + } +} diff --git a/examples/simple_example/lib/flags/local_flags_collector_io.dart b/examples/simple_example/lib/flags/local_flags_collector_io.dart index 77c3c68ba..d413deb2f 100644 --- a/examples/simple_example/lib/flags/local_flags_collector_io.dart +++ b/examples/simple_example/lib/flags/local_flags_collector_io.dart @@ -8,12 +8,18 @@ import 'dart:io'; import 'package:http/http.dart' as http; +import 'flags_request_counter.dart'; import 'local_flags_payloads.dart'; -class LocalFlagsCollector { +class LocalFlagsCollector implements FlagsRequestCounter { final HttpServer _server; + @override + int precomputeRequestCount = 0; + @override int exposureCount = 0; + @override int evaluationRequestCount = 0; + @override int evaluationEventCount = 0; LocalFlagsCollector._(this._server) { @@ -38,6 +44,7 @@ class LocalFlagsCollector { return LocalFlagsCollector._(server); } + @override Future stop() { return _server.close(force: true); } @@ -45,6 +52,7 @@ class LocalFlagsCollector { Future _handleRequest(HttpRequest request) async { final path = request.uri.path; if (path == '/precompute-assignments') { + precomputeRequestCount += 1; await utf8.decoder.bind(request).join(); _writeJson(request.response, localPrecomputeResponse()); } else if (path == '/api/v2/exposures') { @@ -54,7 +62,7 @@ class LocalFlagsCollector { } else if (path == '/api/v2/flagevaluation') { final body = await utf8.decoder.bind(request).join(); evaluationRequestCount += 1; - evaluationEventCount += countEvaluationEvents(body); + evaluationEventCount += tryCountEvaluationEvents(body); _writeJson(request.response, {'ok': true}); } else { request.response.statusCode = HttpStatus.notFound; diff --git a/examples/simple_example/lib/flags/local_flags_collector_stub.dart b/examples/simple_example/lib/flags/local_flags_collector_stub.dart index d5f5699cd..35951957e 100644 --- a/examples/simple_example/lib/flags/local_flags_collector_stub.dart +++ b/examples/simple_example/lib/flags/local_flags_collector_stub.dart @@ -7,12 +7,18 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'flags_request_counter.dart'; import 'local_flags_payloads.dart'; -class LocalFlagsCollector { +class LocalFlagsCollector implements FlagsRequestCounter { final http.Client httpClient; + @override + int precomputeRequestCount = 0; + @override int exposureCount = 0; + @override int evaluationRequestCount = 0; + @override int evaluationEventCount = 0; LocalFlagsCollector._() : httpClient = _LocalFlagsClient(); @@ -31,6 +37,7 @@ class LocalFlagsCollector { return collector; } + @override Future stop() async { httpClient.close(); } @@ -44,6 +51,7 @@ class _LocalFlagsClient extends http.BaseClient { final body = utf8.decode(await request.finalize().toBytes()); final path = request.url.path; if (path == '/precompute-assignments') { + _collector?.precomputeRequestCount += 1; return _jsonResponse(localPrecomputeResponse()); } if (path == '/api/v2/exposures') { @@ -52,7 +60,7 @@ class _LocalFlagsClient extends http.BaseClient { } if (path == '/api/v2/flagevaluation') { _collector?.evaluationRequestCount += 1; - _collector?.evaluationEventCount += countEvaluationEvents(body); + _collector?.evaluationEventCount += tryCountEvaluationEvents(body); return _jsonResponse({'ok': true}); } return _jsonResponse({'error': 'not found'}, statusCode: 404); diff --git a/examples/simple_example/lib/flags/local_flags_payloads.dart b/examples/simple_example/lib/flags/local_flags_payloads.dart index 7de84fc20..6d6e9f1fe 100644 --- a/examples/simple_example/lib/flags/local_flags_payloads.dart +++ b/examples/simple_example/lib/flags/local_flags_payloads.dart @@ -68,3 +68,11 @@ int countEvaluationEvents(String body) { final evaluations = decoded['flagEvaluations'] as List; return evaluations.length; } + +int tryCountEvaluationEvents(String body) { + try { + return countEvaluationEvents(body); + } catch (_) { + return 0; + } +} diff --git a/examples/simple_example/lib/main.dart b/examples/simple_example/lib/main.dart index f0f7faabf..5ae863ea9 100644 --- a/examples/simple_example/lib/main.dart +++ b/examples/simple_example/lib/main.dart @@ -18,6 +18,10 @@ import 'url_strategy_stub.dart' if (dart.library.html) 'url_strategy_web.dart'; const graphQlUrl = 'http://localhost:3000/graphql'; const flagsMode = String.fromEnvironment('FLAGS_MODE', defaultValue: 'local'); +const ddClientToken = String.fromEnvironment('DD_CLIENT_TOKEN'); +const ddApplicationId = String.fromEnvironment('DD_APPLICATION_ID'); +const ddEnv = String.fromEnvironment('DD_ENV'); +const ddSite = String.fromEnvironment('DD_SITE', defaultValue: 'us1'); Future main() async { await dotenv.load(); @@ -27,20 +31,31 @@ Future main() async { DatadogSdk.instance.sdkVerbosity = CoreLoggerLevel.debug; + final siteName = _configValue( + 'DD_SITE', + defineValue: ddSite, + localFallback: 'us1', + ); + final intakeEndpoint = _intakeEndpointForSite(siteName); + final applicationId = _configValue( + 'DD_APPLICATION_ID', + defineValue: ddApplicationId, + localFallback: 'local-application-id', + ); final datadogConfig = DatadogConfiguration( - clientToken: _dotenvValue( + clientToken: _configValue( 'DD_CLIENT_TOKEN', + defineValue: ddClientToken, localFallback: 'local-client-token', ), - env: _dotenvValue('DD_ENV', localFallback: 'local'), - site: DatadogSite.us1, - loggingConfiguration: DatadogLoggingConfiguration(), + env: _configValue('DD_ENV', defineValue: ddEnv, localFallback: 'local'), + site: _siteForName(siteName), + loggingConfiguration: + DatadogLoggingConfiguration(customEndpoint: intakeEndpoint), firstPartyHosts: ['localhost'], rumConfiguration: DatadogRumConfiguration( - applicationId: _dotenvValue( - 'DD_APPLICATION_ID', - localFallback: 'local-application-id', - ), + applicationId: applicationId, + customEndpoint: intakeEndpoint, traceSampleRate: 100.0, trackResourceHeaders: ResourceHeadersExtractor( captureHeaders: [ @@ -71,10 +86,18 @@ Future main() async { // runUsingRunApp(datadogConfig); runUsingAlternativeInit( datadogConfig, + siteName: siteName, ); } -String _dotenvValue(String name, {required String localFallback}) { +String _configValue( + String name, { + required String defineValue, + required String localFallback, +}) { + if (defineValue.isNotEmpty) { + return defineValue; + } final value = dotenv.maybeGet(name); if (value != null && value.isNotEmpty) { return value; @@ -82,7 +105,32 @@ String _dotenvValue(String name, {required String localFallback}) { return flagsMode == 'local' ? localFallback : ''; } -Future runUsingAlternativeInit(DatadogConfiguration datadogConfig) async { +DatadogSite _siteForName(String siteName) { + return switch (siteName) { + 'us3' || 'us3.datadoghq.com' => DatadogSite.us3, + 'us5' || 'us5.datadoghq.com' => DatadogSite.us5, + 'eu1' || 'datadoghq.eu' => DatadogSite.eu1, + 'ap1' || 'ap1.datadoghq.com' => DatadogSite.ap1, + 'ap2' || 'ap2.datadoghq.com' => DatadogSite.ap2, + 'us1_fed' || 'ddog-gov.com' => DatadogSite.us1Fed, + // The Flutter plugin does not expose a staging enum. Use custom endpoints + // for staging intake and flags while keeping the closest SDK site value. + 'datad0g.com' => DatadogSite.us1, + _ => DatadogSite.us1, + }; +} + +String? _intakeEndpointForSite(String siteName) { + return switch (siteName) { + 'datad0g.com' => 'https://browser-intake-datad0g.com', + _ => null, + }; +} + +Future runUsingAlternativeInit( + DatadogConfiguration datadogConfig, { + required String siteName, +}) async { final originalOnError = FlutterError.onError; FlutterError.onError = (details) { FlutterError.presentError(details); @@ -101,7 +149,12 @@ Future runUsingAlternativeInit(DatadogConfiguration datadogConfig) async { }; await DatadogSdk.instance.initialize(datadogConfig, TrackingConsent.granted); - final flagsRuntime = await FlagsDemoRuntime.create(); + final flagsRuntime = await FlagsDemoRuntime.create( + clientToken: datadogConfig.clientToken, + env: datadogConfig.env, + siteName: siteName, + applicationId: datadogConfig.rumConfiguration?.applicationId, + ); await DatadogFlags.enable(configuration: flagsRuntime.configuration); final link = Link.from([ DatadogGqlLink(DatadogSdk.instance, Uri.parse(graphQlUrl)), @@ -119,7 +172,17 @@ Future runUsingRunApp(DatadogConfiguration datadogConfig) async { await DatadogSdk.runApp(datadogConfig, TrackingConsent.granted, () { // This path is not used by default, but keep flags configured for parity // if the example is switched back to DatadogSdk.runApp. - FlagsDemoRuntime.create().then((flagsRuntime) async { + final siteName = _configValue( + 'DD_SITE', + defineValue: ddSite, + localFallback: 'us1', + ); + FlagsDemoRuntime.create( + clientToken: datadogConfig.clientToken, + env: datadogConfig.env, + siteName: siteName, + applicationId: datadogConfig.rumConfiguration?.applicationId, + ).then((flagsRuntime) async { await DatadogFlags.enable(configuration: flagsRuntime.configuration); final link = Link.from([ DatadogGqlLink(DatadogSdk.instance, Uri.parse(graphQlUrl)), diff --git a/examples/simple_example/lib/screens/flags_screen.dart b/examples/simple_example/lib/screens/flags_screen.dart index 2ea620838..2adcb2c9e 100644 --- a/examples/simple_example/lib/screens/flags_screen.dart +++ b/examples/simple_example/lib/screens/flags_screen.dart @@ -20,14 +20,15 @@ class FlagsScreen extends StatefulWidget { class _FlagsScreenState extends State { static const _targetingKey = String.fromEnvironment('FLAGS_TARGETING_KEY', defaultValue: 'flutter-user'); + static const _booleanKeys = String.fromEnvironment('FLAGS_BOOLEAN_KEYS'); + static const _stringKeys = String.fromEnvironment('FLAGS_STRING_KEYS'); + static const _integerKeys = String.fromEnvironment('FLAGS_INTEGER_KEYS'); + static const _doubleKeys = String.fromEnvironment('FLAGS_DOUBLE_KEYS'); + static const _objectKeys = String.fromEnvironment('FLAGS_OBJECT_KEYS'); late final DatadogFlagsClient _client; String _status = 'idle'; - FlagDetails? _enabled; - FlagDetails? _title; - FlagDetails? _limit; - FlagDetails? _ratio; - FlagDetails? _config; + List<_EvaluatedFlag> _flags = []; @override void initState() { @@ -40,52 +41,97 @@ class _FlagsScreenState extends State { setState(() { _status = 'fetching'; }); - await _client.setEvaluationContext(const DatadogFlagsEvaluationContext( - targetingKey: _targetingKey, - attributes: { - 'plan': 'dogfood', - 'platform': 'flutter', - }, - )); - _evaluate(); + try { + await _client.setEvaluationContext(const DatadogFlagsEvaluationContext( + targetingKey: _targetingKey, + attributes: { + 'plan': 'dogfood', + 'platform': 'flutter', + }, + )); + _evaluate(); + } catch (error) { + setState(() { + _status = 'fetch failed: $error'; + _flags = []; + }); + } } void _evaluate() { + final flags = <_EvaluatedFlag>[]; + for (final key in _keys(_booleanKeys, const ['flutter.demo.enabled'])) { + flags.add(_EvaluatedFlag( + label: 'Boolean', + key: key, + details: _client.getBooleanDetails( + key: key, + defaultValue: false, + ), + )); + } + for (final key in _keys(_stringKeys, const ['flutter.demo.title'])) { + flags.add(_EvaluatedFlag( + label: 'String', + key: key, + details: _client.getStringDetails( + key: key, + defaultValue: 'Fallback title', + ), + )); + } + for (final key in _keys(_integerKeys, const ['flutter.demo.limit'])) { + flags.add(_EvaluatedFlag( + label: 'Integer', + key: key, + details: _client.getIntegerDetails( + key: key, + defaultValue: 0, + ), + )); + } + for (final key in _keys(_doubleKeys, const ['flutter.demo.ratio'])) { + flags.add(_EvaluatedFlag( + label: 'Double', + key: key, + details: _client.getDoubleDetails( + key: key, + defaultValue: 0, + ), + )); + } + for (final key in _keys(_objectKeys, const ['flutter.demo.config'])) { + flags.add(_EvaluatedFlag( + label: 'Object', + key: key, + details: _client.getObjectDetails( + key: key, + defaultValue: const {}, + ), + )); + } setState(() { - _enabled = _client.getBooleanDetails( - key: 'flutter.demo.enabled', - defaultValue: false, - ); - _title = _client.getStringDetails( - key: 'flutter.demo.title', - defaultValue: 'Fallback title', - ); - _limit = _client.getIntegerDetails( - key: 'flutter.demo.limit', - defaultValue: 0, - ); - _ratio = _client.getDoubleDetails( - key: 'flutter.demo.ratio', - defaultValue: 0, - ); - _config = _client.getObjectDetails( - key: 'flutter.demo.config', - defaultValue: const {}, - ); + _flags = flags; _status = 'evaluated'; }); } Future _flush() async { - await _client.flush(); - setState(() { - _status = 'flushed'; - }); + try { + await _client.flush(); + setState(() { + _status = 'flushed'; + }); + } catch (error) { + setState(() { + _status = 'flush failed: $error'; + }); + } } @override Widget build(BuildContext context) { - final collector = widget.runtime.collector; + final counter = widget.runtime.counter; return Scaffold( appBar: AppBar(title: const Text('Flags')), body: ListView( @@ -94,29 +140,34 @@ class _FlagsScreenState extends State { _Row(label: 'Mode', value: widget.runtime.mode), _Row(label: 'Status', value: _status), const _Row(label: 'Targeting key', value: _targetingKey), - if (collector != null) ...[ + if (counter != null) ...[ + _Row( + label: 'Precompute requests', + value: '${counter.precomputeRequestCount}', + valueKey: const Key('flags-precompute-request-count'), + ), _Row( label: 'Exposures', - value: '${collector.exposureCount}', + value: '${counter.exposureCount}', valueKey: const Key('flags-exposure-count'), ), _Row( label: 'Evaluation requests', - value: '${collector.evaluationRequestCount}', + value: '${counter.evaluationRequestCount}', valueKey: const Key('flags-evaluation-request-count'), ), _Row( label: 'Evaluation events', - value: '${collector.evaluationEventCount}', + value: '${counter.evaluationEventCount}', valueKey: const Key('flags-evaluation-event-count'), ), ], const SizedBox(height: 16), - _DetailsRow(label: 'Boolean', details: _enabled), - _DetailsRow(label: 'String', details: _title), - _DetailsRow(label: 'Integer', details: _limit), - _DetailsRow(label: 'Double', details: _ratio), - _DetailsRow(label: 'Object', details: _config), + for (final flag in _flags) + _DetailsRow( + label: '${flag.label}\n${flag.key}', + details: flag.details, + ), const SizedBox(height: 16), Wrap( spacing: 12, @@ -139,6 +190,29 @@ class _FlagsScreenState extends State { ), ); } + + List _keys(String configured, List fixtureDefaults) { + if (configured.trim().isNotEmpty) { + return configured + .split(',') + .map((key) => key.trim()) + .where((key) => key.isNotEmpty) + .toList(growable: false); + } + return widget.runtime.usesFixtureFlags ? fixtureDefaults : const []; + } +} + +class _EvaluatedFlag { + final String label; + final String key; + final FlagDetails details; + + const _EvaluatedFlag({ + required this.label, + required this.key, + required this.details, + }); } class _DetailsRow extends StatelessWidget { From 16e8f0a57f9451cba9f3f1b8c54b36cb41fb6465 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 20:43:40 -0400 Subject: [PATCH 03/16] Remove fake local flagging mode --- .../integration_test/flags_dogfood_test.dart | 48 ----------- .../lib/flags/flags_demo_runtime.dart | 42 ++-------- .../lib/flags/forwarding_flags_counter.dart | 21 ++++- .../lib/flags/local_flags_collector.dart | 7 -- .../lib/flags/local_flags_collector_io.dart | 79 ------------------- .../lib/flags/local_flags_collector_stub.dart | 79 ------------------- .../lib/flags/local_flags_payloads.dart | 78 ------------------ examples/simple_example/lib/main.dart | 16 ++-- .../lib/screens/flags_screen.dart | 15 ++-- examples/simple_example/pubspec.lock | 39 --------- examples/simple_example/pubspec.yaml | 2 - .../test/flags_dogfood_web_test.dart | 59 -------------- .../test/flags_request_counter_test.dart | 58 ++++++++++++++ packages/datadog_flags/README.md | 12 ++- 14 files changed, 106 insertions(+), 449 deletions(-) delete mode 100644 examples/simple_example/integration_test/flags_dogfood_test.dart delete mode 100644 examples/simple_example/lib/flags/local_flags_collector.dart delete mode 100644 examples/simple_example/lib/flags/local_flags_collector_io.dart delete mode 100644 examples/simple_example/lib/flags/local_flags_collector_stub.dart delete mode 100644 examples/simple_example/lib/flags/local_flags_payloads.dart delete mode 100644 examples/simple_example/test/flags_dogfood_web_test.dart create mode 100644 examples/simple_example/test/flags_request_counter_test.dart diff --git a/examples/simple_example/integration_test/flags_dogfood_test.dart b/examples/simple_example/integration_test/flags_dogfood_test.dart deleted file mode 100644 index 71bd54e8a..000000000 --- a/examples/simple_example/integration_test/flags_dogfood_test.dart +++ /dev/null @@ -1,48 +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:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:test_app/main.dart' as app; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - testWidgets('flags screen evaluates flags and counts emissions', - (tester) async { - await app.main(); - await tester.pumpAndSettle(const Duration(seconds: 2)); - - await tester.tap(find.text('Flags')); - await tester.pumpAndSettle(const Duration(seconds: 2)); - - expect(find.text('evaluated'), findsOneWidget); - expect(find.textContaining('Datadog Flags'), findsOneWidget); - expect(find.textContaining('variant=enabled'), findsOneWidget); - - await tester.tap(find.text('Flush')); - await tester.pumpAndSettle(const Duration(seconds: 2)); - - expect( - _keyedText('flags-exposure-count', '5'), - findsOneWidget, - ); - expect( - _keyedText('flags-evaluation-request-count', '1'), - findsOneWidget, - ); - expect( - _keyedText('flags-evaluation-event-count', '5'), - findsOneWidget, - ); - }); -} - -Finder _keyedText(String key, String value) { - return find.byWidgetPredicate((widget) { - return widget is Text && widget.key == Key(key) && widget.data == value; - }); -} diff --git a/examples/simple_example/lib/flags/flags_demo_runtime.dart b/examples/simple_example/lib/flags/flags_demo_runtime.dart index bc694bb11..dfa59b03d 100644 --- a/examples/simple_example/lib/flags/flags_demo_runtime.dart +++ b/examples/simple_example/lib/flags/flags_demo_runtime.dart @@ -8,29 +8,26 @@ import 'package:datadog_flutter_plugin/datadog_flutter_plugin.dart'; import 'flags_request_counter.dart'; import 'forwarding_flags_counter.dart'; -import 'local_flags_collector.dart'; const _externalFlagsEndpoint = String.fromEnvironment('FLAGS_ENDPOINT'); const _externalExposureEndpoint = String.fromEnvironment('FLAGS_EXPOSURE_ENDPOINT'); const _externalEvaluationEndpoint = String.fromEnvironment('FLAGS_EVALUATION_ENDPOINT'); +const _countRequests = + bool.fromEnvironment('FLAGS_COUNT_REQUESTS', defaultValue: true); class FlagsDemoRuntime { final String mode; - final LocalFlagsCollector? collector; final FlagsRequestCounter? counter; final DatadogFlagsConfiguration configuration; const FlagsDemoRuntime._({ required this.mode, - required this.collector, required this.counter, required this.configuration, }); - bool get usesFixtureFlags => mode == 'local' || mode == 'fixture'; - Future stop() async { await counter?.stop(); } @@ -41,32 +38,21 @@ class FlagsDemoRuntime { String? siteName, String? applicationId, }) async { - const mode = String.fromEnvironment('FLAGS_MODE', defaultValue: 'local'); + const mode = String.fromEnvironment('FLAGS_MODE', defaultValue: 'live'); final externalFlagsEndpoint = _uriFromEnvironment(_externalFlagsEndpoint); final externalExposureEndpoint = _uriFromEnvironment(_externalExposureEndpoint); final externalEvaluationEndpoint = _uriFromEnvironment(_externalEvaluationEndpoint); - final useFixture = mode == 'local' || mode == 'fixture'; - final useForwardingCounter = !useFixture; final useDatad0g = siteName == 'datad0g.com'; - - LocalFlagsCollector? collector; - if (useFixture && externalFlagsEndpoint == null) { - collector = await LocalFlagsCollector.start(); - } - final forwardingCounter = - useForwardingCounter ? ForwardingFlagsCounter.create() : null; - final counter = collector ?? forwardingCounter; + final counter = _countRequests ? ForwardingFlagsCounter.create() : null; return FlagsDemoRuntime._( mode: mode, - collector: collector, counter: counter, configuration: DatadogFlagsConfiguration( customFlagsEndpoint: externalFlagsEndpoint ?? - collector?.precomputeEndpoint ?? (useDatad0g ? Uri.https( 'preview.ff-cdn.datad0g.com', @@ -75,23 +61,20 @@ class FlagsDemoRuntime { ) : null), customExposureEndpoint: externalExposureEndpoint ?? - collector?.exposureEndpoint ?? (useDatad0g ? Uri.parse( 'https://browser-intake-datad0g.com/api/v2/exposures?ddsource=flutter', ) : null), customEvaluationEndpoint: externalEvaluationEndpoint ?? - collector?.evaluationEndpoint ?? (useDatad0g ? Uri.parse( 'https://browser-intake-datad0g.com/api/v2/flagevaluation?ddsource=flutter', ) : null), - httpClient: collector?.httpClient ?? forwardingCounter?.httpClient, - store: useFixture ? InMemoryDatadogFlagsStore() : null, + httpClient: + counter is ForwardingFlagsCounter ? counter.httpClient : null, datadogContext: _datadogContext( - useFixture: useFixture, useDatad0g: useDatad0g, clientToken: clientToken, env: env, @@ -104,24 +87,11 @@ class FlagsDemoRuntime { } DatadogFlagsContext? _datadogContext({ - required bool useFixture, required bool useDatad0g, required String? clientToken, required String? env, required String? applicationId, }) { - if (useFixture) { - return const DatadogFlagsContext( - clientToken: 'local-client-token', - env: 'local', - site: DatadogSite.us1, - service: 'simple-example', - version: '1.0.0', - applicationId: 'local-application-id', - sdkVersion: '3.3.0', - ); - } - if (!useDatad0g) { return null; } diff --git a/examples/simple_example/lib/flags/forwarding_flags_counter.dart b/examples/simple_example/lib/flags/forwarding_flags_counter.dart index 41b67bf07..e79c1bb9b 100644 --- a/examples/simple_example/lib/flags/forwarding_flags_counter.dart +++ b/examples/simple_example/lib/flags/forwarding_flags_counter.dart @@ -3,10 +3,11 @@ // developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import 'dart:convert'; + import 'package:http/http.dart' as http; import 'flags_request_counter.dart'; -import 'local_flags_payloads.dart'; class ForwardingFlagsCounter implements FlagsRequestCounter { final CountingFlagsHttpClient httpClient; @@ -52,10 +53,10 @@ class CountingFlagsHttpClient extends http.BaseClient { if (path == '/precompute-assignments') { precomputeRequestCount += 1; } else if (path == '/api/v2/exposures') { - exposureCount += countExposureBody(body); + exposureCount += _countExposureBody(body); } else if (path == '/api/v2/flagevaluation') { evaluationRequestCount += 1; - evaluationEventCount += tryCountEvaluationEvents(body); + evaluationEventCount += _tryCountEvaluationEvents(body); } return _inner.send(request); } @@ -66,3 +67,17 @@ class CountingFlagsHttpClient extends http.BaseClient { super.close(); } } + +int _countExposureBody(String body) { + return body.split('\n').where((line) => line.trim().isNotEmpty).length; +} + +int _tryCountEvaluationEvents(String body) { + try { + final decoded = jsonDecode(body) as Map; + final evaluations = decoded['flagEvaluations'] as List; + return evaluations.length; + } catch (_) { + return 0; + } +} diff --git a/examples/simple_example/lib/flags/local_flags_collector.dart b/examples/simple_example/lib/flags/local_flags_collector.dart deleted file mode 100644 index 1241287c7..000000000 --- a/examples/simple_example/lib/flags/local_flags_collector.dart +++ /dev/null @@ -1,7 +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. - -export 'local_flags_collector_stub.dart' - if (dart.library.io) 'local_flags_collector_io.dart'; diff --git a/examples/simple_example/lib/flags/local_flags_collector_io.dart b/examples/simple_example/lib/flags/local_flags_collector_io.dart deleted file mode 100644 index d413deb2f..000000000 --- a/examples/simple_example/lib/flags/local_flags_collector_io.dart +++ /dev/null @@ -1,79 +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 'dart:convert'; -import 'dart:io'; - -import 'package:http/http.dart' as http; - -import 'flags_request_counter.dart'; -import 'local_flags_payloads.dart'; - -class LocalFlagsCollector implements FlagsRequestCounter { - final HttpServer _server; - @override - int precomputeRequestCount = 0; - @override - int exposureCount = 0; - @override - int evaluationRequestCount = 0; - @override - int evaluationEventCount = 0; - - LocalFlagsCollector._(this._server) { - _server.listen(_handleRequest); - } - - Uri get _baseUri => Uri( - scheme: 'http', - host: InternetAddress.loopbackIPv4.address, - port: _server.port, - ); - - Uri get precomputeEndpoint => - _baseUri.replace(path: '/precompute-assignments'); - Uri get exposureEndpoint => _baseUri.replace(path: '/api/v2/exposures'); - Uri get evaluationEndpoint => - _baseUri.replace(path: '/api/v2/flagevaluation'); - http.Client? get httpClient => null; - - static Future start() async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - return LocalFlagsCollector._(server); - } - - @override - Future stop() { - return _server.close(force: true); - } - - Future _handleRequest(HttpRequest request) async { - final path = request.uri.path; - if (path == '/precompute-assignments') { - precomputeRequestCount += 1; - await utf8.decoder.bind(request).join(); - _writeJson(request.response, localPrecomputeResponse()); - } else if (path == '/api/v2/exposures') { - final body = await utf8.decoder.bind(request).join(); - exposureCount += countExposureBody(body); - _writeJson(request.response, {'ok': true}); - } else if (path == '/api/v2/flagevaluation') { - final body = await utf8.decoder.bind(request).join(); - evaluationRequestCount += 1; - evaluationEventCount += tryCountEvaluationEvents(body); - _writeJson(request.response, {'ok': true}); - } else { - request.response.statusCode = HttpStatus.notFound; - await request.response.close(); - } - } - - void _writeJson(HttpResponse response, Object body) { - response.statusCode = HttpStatus.ok; - response.headers.contentType = ContentType.json; - response.write(jsonEncode(body)); - response.close(); - } -} diff --git a/examples/simple_example/lib/flags/local_flags_collector_stub.dart b/examples/simple_example/lib/flags/local_flags_collector_stub.dart deleted file mode 100644 index 35951957e..000000000 --- a/examples/simple_example/lib/flags/local_flags_collector_stub.dart +++ /dev/null @@ -1,79 +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 'dart:convert'; - -import 'package:http/http.dart' as http; - -import 'flags_request_counter.dart'; -import 'local_flags_payloads.dart'; - -class LocalFlagsCollector implements FlagsRequestCounter { - final http.Client httpClient; - @override - int precomputeRequestCount = 0; - @override - int exposureCount = 0; - @override - int evaluationRequestCount = 0; - @override - int evaluationEventCount = 0; - - LocalFlagsCollector._() : httpClient = _LocalFlagsClient(); - - Uri get _baseUri => Uri.parse('https://local.datadog.flags'); - - Uri get precomputeEndpoint => - _baseUri.replace(path: '/precompute-assignments'); - Uri get exposureEndpoint => _baseUri.replace(path: '/api/v2/exposures'); - Uri get evaluationEndpoint => - _baseUri.replace(path: '/api/v2/flagevaluation'); - - static Future start() async { - final collector = LocalFlagsCollector._(); - (collector.httpClient as _LocalFlagsClient)._collector = collector; - return collector; - } - - @override - Future stop() async { - httpClient.close(); - } -} - -class _LocalFlagsClient extends http.BaseClient { - LocalFlagsCollector? _collector; - - @override - Future send(http.BaseRequest request) async { - final body = utf8.decode(await request.finalize().toBytes()); - final path = request.url.path; - if (path == '/precompute-assignments') { - _collector?.precomputeRequestCount += 1; - return _jsonResponse(localPrecomputeResponse()); - } - if (path == '/api/v2/exposures') { - _collector?.exposureCount += countExposureBody(body); - return _jsonResponse({'ok': true}); - } - if (path == '/api/v2/flagevaluation') { - _collector?.evaluationRequestCount += 1; - _collector?.evaluationEventCount += tryCountEvaluationEvents(body); - return _jsonResponse({'ok': true}); - } - return _jsonResponse({'error': 'not found'}, statusCode: 404); - } - - http.StreamedResponse _jsonResponse( - Object body, { - int statusCode = 200, - }) { - return http.StreamedResponse( - Stream.value(utf8.encode(jsonEncode(body))), - statusCode, - headers: {'content-type': 'application/json'}, - ); - } -} diff --git a/examples/simple_example/lib/flags/local_flags_payloads.dart b/examples/simple_example/lib/flags/local_flags_payloads.dart deleted file mode 100644 index 6d6e9f1fe..000000000 --- a/examples/simple_example/lib/flags/local_flags_payloads.dart +++ /dev/null @@ -1,78 +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 'dart:convert'; - -Map localPrecomputeResponse() { - return { - 'data': { - 'attributes': { - 'flags': { - 'flutter.demo.enabled': { - 'allocationKey': 'allocation-mobile-demo', - 'variationKey': 'enabled', - 'variationType': 'boolean', - 'variationValue': true, - 'reason': 'TARGETING_MATCH', - 'doLog': true, - }, - 'flutter.demo.title': { - 'allocationKey': 'allocation-mobile-demo', - 'variationKey': 'copy-a', - 'variationType': 'string', - 'variationValue': 'Datadog Flags', - 'reason': 'TARGETING_MATCH', - 'doLog': true, - }, - 'flutter.demo.limit': { - 'allocationKey': 'allocation-mobile-demo', - 'variationKey': 'limit-five', - 'variationType': 'integer', - 'variationValue': 5, - 'reason': 'TARGETING_MATCH', - 'doLog': true, - }, - 'flutter.demo.ratio': { - 'allocationKey': 'allocation-mobile-demo', - 'variationKey': 'half', - 'variationType': 'float', - 'variationValue': 0.5, - 'reason': 'TARGETING_MATCH', - 'doLog': true, - }, - 'flutter.demo.config': { - 'allocationKey': 'allocation-mobile-demo', - 'variationKey': 'object-a', - 'variationType': 'object', - 'variationValue': { - 'showBanner': true, - 'colors': ['blue', 'green'], - }, - 'reason': 'TARGETING_MATCH', - 'doLog': true, - }, - }, - }, - }, - }; -} - -int countExposureBody(String body) { - return body.split('\n').where((line) => line.trim().isNotEmpty).length; -} - -int countEvaluationEvents(String body) { - final decoded = jsonDecode(body) as Map; - final evaluations = decoded['flagEvaluations'] as List; - return evaluations.length; -} - -int tryCountEvaluationEvents(String body) { - try { - return countEvaluationEvents(body); - } catch (_) { - return 0; - } -} diff --git a/examples/simple_example/lib/main.dart b/examples/simple_example/lib/main.dart index 5ae863ea9..1c6a3ec40 100644 --- a/examples/simple_example/lib/main.dart +++ b/examples/simple_example/lib/main.dart @@ -17,7 +17,7 @@ import 'flags/flags_demo_runtime.dart'; import 'url_strategy_stub.dart' if (dart.library.html) 'url_strategy_web.dart'; const graphQlUrl = 'http://localhost:3000/graphql'; -const flagsMode = String.fromEnvironment('FLAGS_MODE', defaultValue: 'local'); +const flagsMode = String.fromEnvironment('FLAGS_MODE', defaultValue: 'live'); const ddClientToken = String.fromEnvironment('DD_CLIENT_TOKEN'); const ddApplicationId = String.fromEnvironment('DD_APPLICATION_ID'); const ddEnv = String.fromEnvironment('DD_ENV'); @@ -34,21 +34,21 @@ Future main() async { final siteName = _configValue( 'DD_SITE', defineValue: ddSite, - localFallback: 'us1', + defaultValue: 'us1', ); final intakeEndpoint = _intakeEndpointForSite(siteName); final applicationId = _configValue( 'DD_APPLICATION_ID', defineValue: ddApplicationId, - localFallback: 'local-application-id', + defaultValue: '', ); final datadogConfig = DatadogConfiguration( clientToken: _configValue( 'DD_CLIENT_TOKEN', defineValue: ddClientToken, - localFallback: 'local-client-token', + defaultValue: '', ), - env: _configValue('DD_ENV', defineValue: ddEnv, localFallback: 'local'), + env: _configValue('DD_ENV', defineValue: ddEnv, defaultValue: 'dev'), site: _siteForName(siteName), loggingConfiguration: DatadogLoggingConfiguration(customEndpoint: intakeEndpoint), @@ -93,7 +93,7 @@ Future main() async { String _configValue( String name, { required String defineValue, - required String localFallback, + required String defaultValue, }) { if (defineValue.isNotEmpty) { return defineValue; @@ -102,7 +102,7 @@ String _configValue( if (value != null && value.isNotEmpty) { return value; } - return flagsMode == 'local' ? localFallback : ''; + return defaultValue; } DatadogSite _siteForName(String siteName) { @@ -175,7 +175,7 @@ Future runUsingRunApp(DatadogConfiguration datadogConfig) async { final siteName = _configValue( 'DD_SITE', defineValue: ddSite, - localFallback: 'us1', + defaultValue: 'us1', ); FlagsDemoRuntime.create( clientToken: datadogConfig.clientToken, diff --git a/examples/simple_example/lib/screens/flags_screen.dart b/examples/simple_example/lib/screens/flags_screen.dart index 2adcb2c9e..b7abb0e71 100644 --- a/examples/simple_example/lib/screens/flags_screen.dart +++ b/examples/simple_example/lib/screens/flags_screen.dart @@ -60,7 +60,7 @@ class _FlagsScreenState extends State { void _evaluate() { final flags = <_EvaluatedFlag>[]; - for (final key in _keys(_booleanKeys, const ['flutter.demo.enabled'])) { + for (final key in _keys(_booleanKeys, const [])) { flags.add(_EvaluatedFlag( label: 'Boolean', key: key, @@ -70,7 +70,8 @@ class _FlagsScreenState extends State { ), )); } - for (final key in _keys(_stringKeys, const ['flutter.demo.title'])) { + for (final key + in _keys(_stringKeys, const ['ffe-dogfooding-string-flag'])) { flags.add(_EvaluatedFlag( label: 'String', key: key, @@ -80,7 +81,7 @@ class _FlagsScreenState extends State { ), )); } - for (final key in _keys(_integerKeys, const ['flutter.demo.limit'])) { + for (final key in _keys(_integerKeys, const [])) { flags.add(_EvaluatedFlag( label: 'Integer', key: key, @@ -90,7 +91,7 @@ class _FlagsScreenState extends State { ), )); } - for (final key in _keys(_doubleKeys, const ['flutter.demo.ratio'])) { + for (final key in _keys(_doubleKeys, const [])) { flags.add(_EvaluatedFlag( label: 'Double', key: key, @@ -100,7 +101,7 @@ class _FlagsScreenState extends State { ), )); } - for (final key in _keys(_objectKeys, const ['flutter.demo.config'])) { + for (final key in _keys(_objectKeys, const [])) { flags.add(_EvaluatedFlag( label: 'Object', key: key, @@ -191,7 +192,7 @@ class _FlagsScreenState extends State { ); } - List _keys(String configured, List fixtureDefaults) { + List _keys(String configured, List defaultKeys) { if (configured.trim().isNotEmpty) { return configured .split(',') @@ -199,7 +200,7 @@ class _FlagsScreenState extends State { .where((key) => key.isNotEmpty) .toList(growable: false); } - return widget.runtime.usesFixtureFlags ? fixtureDefaults : const []; + return defaultKeys; } } diff --git a/examples/simple_example/pubspec.lock b/examples/simple_example/pubspec.lock index d4282d5ad..d4caee55c 100644 --- a/examples/simple_example/pubspec.lock +++ b/examples/simple_example/pubspec.lock @@ -176,11 +176,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.2.1" - flutter_driver: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" flutter_hooks: dependency: transitive description: @@ -207,11 +202,6 @@ packages: description: flutter source: sdk version: "0.0.0" - fuchsia_remote_debug_protocol: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" go_router: dependency: "direct main" description: @@ -316,11 +306,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" - integration_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" jni: dependency: transitive description: @@ -513,14 +498,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - process: - dependency: transitive - description: - name: process - sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 - url: "https://pub.dev" - source: hosted - version: "5.0.5" pub_semver: dependency: transitive description: @@ -638,14 +615,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - sync_http: - dependency: transitive - description: - name: sync_http - sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" - url: "https://pub.dev" - source: hosted - version: "0.3.1" term_glyph: dependency: transitive description: @@ -726,14 +695,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" - webdriver: - dependency: transitive - description: - name: webdriver - sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" - url: "https://pub.dev" - source: hosted - version: "3.1.0" webview_flutter: dependency: transitive description: diff --git a/examples/simple_example/pubspec.yaml b/examples/simple_example/pubspec.yaml index 07fdd1bec..786014733 100644 --- a/examples/simple_example/pubspec.yaml +++ b/examples/simple_example/pubspec.yaml @@ -42,8 +42,6 @@ dependency_overrides: dev_dependencies: flutter_test: sdk: flutter - integration_test: - sdk: flutter flutter_lints: ^5.0.0 flutter: diff --git a/examples/simple_example/test/flags_dogfood_web_test.dart b/examples/simple_example/test/flags_dogfood_web_test.dart deleted file mode 100644 index 33f8873e6..000000000 --- a/examples/simple_example/test/flags_dogfood_web_test.dart +++ /dev/null @@ -1,59 +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:flutter_test/flutter_test.dart'; -import 'package:test_app/flags/flags_demo_runtime.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - test('web local flags collector evaluates flags and counts emissions', - () async { - FlagsDemoRuntime? flagsRuntime; - try { - flagsRuntime = await FlagsDemoRuntime.create(); - await DatadogFlags.enable(configuration: flagsRuntime.configuration); - final client = DatadogFlags.sharedClient(); - - await client.setEvaluationContext(const DatadogFlagsEvaluationContext( - targetingKey: 'flutter-user', - attributes: { - 'plan': 'dogfood', - 'platform': 'flutter', - }, - )); - - final enabled = client.getBooleanDetails( - key: 'flutter.demo.enabled', - defaultValue: false, - ); - final title = client.getStringDetails( - key: 'flutter.demo.title', - defaultValue: 'Fallback title', - ); - client.getIntegerDetails(key: 'flutter.demo.limit', defaultValue: 0); - client.getDoubleDetails(key: 'flutter.demo.ratio', defaultValue: 0); - client.getObjectDetails(key: 'flutter.demo.config', defaultValue: {}); - - expect(enabled.value, isTrue); - expect(enabled.variant, 'enabled'); - expect(title.value, 'Datadog Flags'); - expect(title.variant, 'copy-a'); - - await Future.delayed(Duration.zero); - await client.flush(); - - final collector = flagsRuntime.collector; - expect(collector, isNotNull); - expect(collector!.exposureCount, 5); - expect(collector.evaluationRequestCount, 1); - expect(collector.evaluationEventCount, 5); - } finally { - await DatadogFlags.disable(); - await flagsRuntime?.stop(); - } - }); -} diff --git a/examples/simple_example/test/flags_request_counter_test.dart b/examples/simple_example/test/flags_request_counter_test.dart new file mode 100644 index 000000000..3f3be673a --- /dev/null +++ b/examples/simple_example/test/flags_request_counter_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. + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test_app/flags/forwarding_flags_counter.dart'; + +void main() { + test('forwarding counter records flag request attempts and forwards them', + () async { + final forwarded = []; + final client = CountingFlagsHttpClient(MockClient((request) async { + forwarded.add(request); + return http.Response('{}', 202); + })); + + await client.post( + Uri.https( + 'preview.ff-cdn.datad0g.com', + '/precompute-assignments', + {'dd_env': 'dev'}, + ), + body: '{}', + ); + await client.post( + Uri.https( + 'browser-intake-datad0g.com', + '/api/v2/exposures', + {'ddsource': 'flutter'}, + ), + body: '{"flag":{"key":"flag-a"}}', + ); + await client.post( + Uri.https( + 'browser-intake-datad0g.com', + '/api/v2/flagevaluation', + {'ddsource': 'flutter'}, + ), + body: jsonEncode({ + 'flagEvaluations': [ + {'flag_key': 'flag-a'}, + {'flag_key': 'flag-b'}, + ], + }), + ); + + expect(forwarded, hasLength(3)); + expect(client.precomputeRequestCount, 1); + expect(client.exposureCount, 1); + expect(client.evaluationRequestCount, 1); + expect(client.evaluationEventCount, 2); + }); +} diff --git a/packages/datadog_flags/README.md b/packages/datadog_flags/README.md index 37256245f..c1fd496ea 100644 --- a/packages/datadog_flags/README.md +++ b/packages/datadog_flags/README.md @@ -66,11 +66,15 @@ flutter test test flutter test --platform chrome test ``` -The example app includes a local fixture mode for iOS, Android, and web: +The example app includes a live dogfood screen with optional forwarding +request counters: ```bash cd ../../examples/simple_example -flutter test integration_test/flags_dogfood_test.dart -d --dart-define FLAGS_MODE=local -flutter test integration_test/flags_dogfood_test.dart -d --dart-define FLAGS_MODE=local -flutter test --platform chrome test/flags_dogfood_web_test.dart --dart-define FLAGS_MODE=local +flutter run -d \ + --dart-define DD_SITE=datad0g.com \ + --dart-define DD_ENV=dev \ + --dart-define DD_CLIENT_TOKEN= \ + --dart-define FLAGS_COUNT_REQUESTS=true \ + --dart-define FLAGS_STRING_KEYS=ffe-dogfooding-string-flag ``` From 30770e6626c71ea9193ee2737235da21f489a471 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 20:45:50 -0400 Subject: [PATCH 04/16] Add flags screen home navigation --- .../simple_example/lib/screens/flags_screen.dart | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/examples/simple_example/lib/screens/flags_screen.dart b/examples/simple_example/lib/screens/flags_screen.dart index b7abb0e71..fc3891b20 100644 --- a/examples/simple_example/lib/screens/flags_screen.dart +++ b/examples/simple_example/lib/screens/flags_screen.dart @@ -5,6 +5,7 @@ import 'package:datadog_flags/datadog_flags.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../flags/flags_demo_runtime.dart'; @@ -134,7 +135,17 @@ class _FlagsScreenState extends State { Widget build(BuildContext context) { final counter = widget.runtime.counter; return Scaffold( - appBar: AppBar(title: const Text('Flags')), + appBar: AppBar( + title: const Text('Flags'), + actions: [ + IconButton( + key: const Key('flags-home-button'), + tooltip: 'Home', + icon: const Icon(Icons.home), + onPressed: () => context.go('/home'), + ), + ], + ), body: ListView( padding: const EdgeInsets.all(16), children: [ From 23bc9f347154f46c6aadc5bb95928e2f2f65a565 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 20:49:41 -0400 Subject: [PATCH 05/16] Align flags dogfood evaluation telemetry --- .../lib/screens/flags_screen.dart | 63 +++++++++++-------- .../test/datadog_flags_test.dart | 36 +++++++++++ 2 files changed, 73 insertions(+), 26 deletions(-) diff --git a/examples/simple_example/lib/screens/flags_screen.dart b/examples/simple_example/lib/screens/flags_screen.dart index fc3891b20..336a65e32 100644 --- a/examples/simple_example/lib/screens/flags_screen.dart +++ b/examples/simple_example/lib/screens/flags_screen.dart @@ -3,6 +3,8 @@ // developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import 'dart:async'; + import 'package:datadog_flags/datadog_flags.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -28,19 +30,37 @@ class _FlagsScreenState extends State { static const _objectKeys = String.fromEnvironment('FLAGS_OBJECT_KEYS'); late final DatadogFlagsClient _client; - String _status = 'idle'; + Timer? _counterRefreshTimer; + String _assignmentState = 'idle'; + int _recordedEvaluationCount = 0; List<_EvaluatedFlag> _flags = []; @override void initState() { super.initState(); _client = DatadogFlagsClient.shared(); + if (widget.runtime.counter != null) { + _counterRefreshTimer = Timer.periodic( + const Duration(milliseconds: 500), + (_) { + if (mounted) { + setState(() {}); + } + }, + ); + } _refreshFlags(); } + @override + void dispose() { + _counterRefreshTimer?.cancel(); + super.dispose(); + } + Future _refreshFlags() async { setState(() { - _status = 'fetching'; + _assignmentState = 'fetching'; }); try { await _client.setEvaluationContext(const DatadogFlagsEvaluationContext( @@ -51,9 +71,12 @@ class _FlagsScreenState extends State { }, )); _evaluate(); + setState(() { + _assignmentState = 'ready'; + }); } catch (error) { setState(() { - _status = 'fetch failed: $error'; + _assignmentState = 'fetch failed: $error'; _flags = []; }); } @@ -114,23 +137,10 @@ class _FlagsScreenState extends State { } setState(() { _flags = flags; - _status = 'evaluated'; + _recordedEvaluationCount += flags.length; }); } - Future _flush() async { - try { - await _client.flush(); - setState(() { - _status = 'flushed'; - }); - } catch (error) { - setState(() { - _status = 'flush failed: $error'; - }); - } - } - @override Widget build(BuildContext context) { final counter = widget.runtime.counter; @@ -150,8 +160,13 @@ class _FlagsScreenState extends State { padding: const EdgeInsets.all(16), children: [ _Row(label: 'Mode', value: widget.runtime.mode), - _Row(label: 'Status', value: _status), + _Row(label: 'Assignments', value: _assignmentState), const _Row(label: 'Targeting key', value: _targetingKey), + _Row( + label: 'Evaluations recorded', + value: '$_recordedEvaluationCount', + valueKey: const Key('flags-recorded-evaluation-count'), + ), if (counter != null) ...[ _Row( label: 'Precompute requests', @@ -164,12 +179,12 @@ class _FlagsScreenState extends State { valueKey: const Key('flags-exposure-count'), ), _Row( - label: 'Evaluation requests', + label: 'Flageval batches sent', value: '${counter.evaluationRequestCount}', valueKey: const Key('flags-evaluation-request-count'), ), _Row( - label: 'Evaluation events', + label: 'Flageval events sent', value: '${counter.evaluationEventCount}', valueKey: const Key('flags-evaluation-event-count'), ), @@ -186,15 +201,11 @@ class _FlagsScreenState extends State { children: [ ElevatedButton( onPressed: _refreshFlags, - child: const Text('Set context'), + child: const Text('Refresh assignments'), ), ElevatedButton( onPressed: _evaluate, - child: const Text('Evaluate'), - ), - ElevatedButton( - onPressed: _flush, - child: const Text('Flush'), + child: const Text('Evaluate flags'), ), ], ), diff --git a/packages/datadog_flags/test/datadog_flags_test.dart b/packages/datadog_flags/test/datadog_flags_test.dart index b3486ced3..9fb0b9017 100644 --- a/packages/datadog_flags/test/datadog_flags_test.dart +++ b/packages/datadog_flags/test/datadog_flags_test.dart @@ -149,6 +149,19 @@ void main() { .toList(); } + Future waitUntil( + bool Function() predicate, { + Duration timeout = const Duration(seconds: 3), + }) async { + final deadline = DateTime.now().add(timeout); + while (!predicate()) { + if (DateTime.now().isAfter(deadline)) { + fail('Timed out waiting for condition'); + } + await Future.delayed(const Duration(milliseconds: 50)); + } + } + test('enable creates the default shared client', () async { await DatadogFlags.enable( configuration: DatadogFlagsConfiguration( @@ -555,6 +568,29 @@ void main() { }); expect(flagNotFound['runtime_default_used'], isTrue); }); + + test('sends aggregated evaluation metrics on the configured timer', () async { + final client = await createClient( + httpClient: clientWithResponse(assignmentsResponse()), + trackExposures: false, + ); + await client.setEvaluationContext( + const DatadogFlagsEvaluationContext(targetingKey: 'user-123'), + ); + + client.getBooleanValue(key: 'show-paywall', defaultValue: false); + + expect(evaluationRequests(), isEmpty); + await waitUntil(() => evaluationRequests().isNotEmpty); + + final batch = + jsonDecode(evaluationRequests().single.body) as Map; + final evaluations = batch['flagEvaluations'] as List; + expect(evaluations, hasLength(1)); + final evaluation = evaluations.single as Map; + expect(evaluation['flag'], {'key': 'show-paywall'}); + expect(evaluation['evaluation_count'], 1); + }); } class FakeRumFlagEvaluationReporter implements RumFlagEvaluationReporter { From d1f78d099da777cd56dbe30fa957a81e525c6afc Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 21:11:19 -0400 Subject: [PATCH 06/16] Fix live flags precompute request shape --- examples/simple_example/ios/Podfile.lock | 6 ---- .../lib/flags/flags_demo_runtime.dart | 1 - .../lib/screens/flags_screen.dart | 29 +++++++++++++++---- .../test/flags_request_counter_test.dart | 1 - .../lib/src/flag_assignments_fetcher.dart | 6 ---- .../test/datadog_flags_test.dart | 8 ++--- 6 files changed, 26 insertions(+), 25 deletions(-) diff --git a/examples/simple_example/ios/Podfile.lock b/examples/simple_example/ios/Podfile.lock index 06b22856e..f7d23c21d 100644 --- a/examples/simple_example/ios/Podfile.lock +++ b/examples/simple_example/ios/Podfile.lock @@ -33,8 +33,6 @@ PODS: - DatadogInternal (= 3.11.1) - DictionaryCoder (1.2.0) - Flutter (1.0.0) - - integration_test (0.0.1): - - Flutter - KSCrash/Core (2.5.0) - KSCrash/Filters (2.5.0): - KSCrash/Recording @@ -64,7 +62,6 @@ DEPENDENCIES: - datadog_session_replay (from `.symlinks/plugins/datadog_session_replay/ios`) - datadog_webview_tracking (from `.symlinks/plugins/datadog_webview_tracking/ios`) - Flutter (from `Flutter`) - - integration_test (from `.symlinks/plugins/integration_test/ios`) - objective_c (from `.symlinks/plugins/objective_c/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -92,8 +89,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/datadog_webview_tracking/ios" Flutter: :path: Flutter - integration_test: - :path: ".symlinks/plugins/integration_test/ios" objective_c: :path: ".symlinks/plugins/objective_c/ios" path_provider_foundation: @@ -116,7 +111,6 @@ SPEC CHECKSUMS: DatadogWebViewTracking: 99631d80d34cb30cf6be9b620dc6659f7f5bd15f DictionaryCoder: f7115fcd074c8301e91f2eb862da1ea7d0385e61 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e KSCrash: 80e1e24eaefbe5134934ae11ca8d7746586bc2ed objective_c: 89e720c30d716b036faf9c9684022048eee1eee2 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 diff --git a/examples/simple_example/lib/flags/flags_demo_runtime.dart b/examples/simple_example/lib/flags/flags_demo_runtime.dart index dfa59b03d..dc22afb20 100644 --- a/examples/simple_example/lib/flags/flags_demo_runtime.dart +++ b/examples/simple_example/lib/flags/flags_demo_runtime.dart @@ -57,7 +57,6 @@ class FlagsDemoRuntime { ? Uri.https( 'preview.ff-cdn.datad0g.com', '/precompute-assignments', - {'dd_env': env ?? 'dev'}, ) : null), customExposureEndpoint: externalExposureEndpoint ?? diff --git a/examples/simple_example/lib/screens/flags_screen.dart b/examples/simple_example/lib/screens/flags_screen.dart index 336a65e32..da221d9cd 100644 --- a/examples/simple_example/lib/screens/flags_screen.dart +++ b/examples/simple_example/lib/screens/flags_screen.dart @@ -4,6 +4,7 @@ // Copyright 2019-Present Datadog, Inc. import 'dart:async'; +import 'dart:convert'; import 'package:datadog_flags/datadog_flags.dart'; import 'package:flutter/material.dart'; @@ -22,7 +23,11 @@ class FlagsScreen extends StatefulWidget { class _FlagsScreenState extends State { static const _targetingKey = String.fromEnvironment('FLAGS_TARGETING_KEY', - defaultValue: 'flutter-user'); + defaultValue: 'test_subject4'); + static const _targetingAttributesJson = String.fromEnvironment( + 'FLAGS_TARGETING_ATTRIBUTES_JSON', + defaultValue: '{"attr1":"value1","companyId":"1"}', + ); static const _booleanKeys = String.fromEnvironment('FLAGS_BOOLEAN_KEYS'); static const _stringKeys = String.fromEnvironment('FLAGS_STRING_KEYS'); static const _integerKeys = String.fromEnvironment('FLAGS_INTEGER_KEYS'); @@ -63,12 +68,9 @@ class _FlagsScreenState extends State { _assignmentState = 'fetching'; }); try { - await _client.setEvaluationContext(const DatadogFlagsEvaluationContext( + await _client.setEvaluationContext(DatadogFlagsEvaluationContext( targetingKey: _targetingKey, - attributes: { - 'plan': 'dogfood', - 'platform': 'flutter', - }, + attributes: _targetingAttributes(), )); _evaluate(); setState(() { @@ -224,6 +226,21 @@ class _FlagsScreenState extends State { } return defaultKeys; } + + static Map _targetingAttributes() { + try { + final decoded = jsonDecode(_targetingAttributesJson); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return Map.from(decoded); + } + } catch (_) { + return const {}; + } + return const {}; + } } class _EvaluatedFlag { diff --git a/examples/simple_example/test/flags_request_counter_test.dart b/examples/simple_example/test/flags_request_counter_test.dart index 3f3be673a..759a68af8 100644 --- a/examples/simple_example/test/flags_request_counter_test.dart +++ b/examples/simple_example/test/flags_request_counter_test.dart @@ -23,7 +23,6 @@ void main() { Uri.https( 'preview.ff-cdn.datad0g.com', '/precompute-assignments', - {'dd_env': 'dev'}, ), body: '{}', ); diff --git a/packages/datadog_flags/lib/src/flag_assignments_fetcher.dart b/packages/datadog_flags/lib/src/flag_assignments_fetcher.dart index bf56ad680..38483d068 100644 --- a/packages/datadog_flags/lib/src/flag_assignments_fetcher.dart +++ b/packages/datadog_flags/lib/src/flag_assignments_fetcher.dart @@ -82,7 +82,6 @@ class FlagAssignmentsFetcher { Map _headers() { return { 'Content-Type': 'application/vnd.api+json', - 'Accept-Encoding': 'gzip, deflate, br', 'dd-client-token': datadogContext.clientToken, if (datadogContext.applicationId != null) 'dd-application-id': datadogContext.applicationId!, @@ -98,13 +97,8 @@ class FlagAssignmentsFetcher { 'type': 'precompute-assignments-request', 'attributes': { 'env': { - 'name': datadogContext.env, 'dd_env': datadogContext.env, }, - 'source': { - 'sdk_name': 'dd-sdk-flutter', - 'sdk_version': datadogContext.sdkVersion, - }, 'subject': { 'targeting_key': evaluationContext.targetingKey, 'targeting_attributes': sanitizeJsonValue( diff --git a/packages/datadog_flags/test/datadog_flags_test.dart b/packages/datadog_flags/test/datadog_flags_test.dart index 9fb0b9017..e9c57f49f 100644 --- a/packages/datadog_flags/test/datadog_flags_test.dart +++ b/packages/datadog_flags/test/datadog_flags_test.dart @@ -197,16 +197,14 @@ void main() { ); expect(requests.single.headers['Content-Type'], 'application/vnd.api+json'); expect(requests.single.headers['dd-client-token'], 'client-token'); + expect(requests.single.headers.containsKey('Accept-Encoding'), isFalse); expect(requests.single.headers['dd-application-id'], 'rum-app-id'); final body = jsonDecode(requests.single.body) as Map; final attributes = ((body['data'] as Map)['attributes'] as Map); - expect(attributes['env'], {'name': 'staging', 'dd_env': 'staging'}); - expect(attributes['source'], { - 'sdk_name': 'dd-sdk-flutter', - 'sdk_version': '9.8.7', - }); + expect(attributes['env'], {'dd_env': 'staging'}); + expect(attributes.containsKey('source'), isFalse); expect(attributes['subject'], { 'targeting_key': 'user-123', 'targeting_attributes': {'plan': 'pro', 'seat_count': 3}, From 8dc6b0facad2feb8e39122cc64dfcdc730323f1d Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 21:13:29 -0400 Subject: [PATCH 07/16] Remove flags mode from dogfood screen --- examples/simple_example/lib/flags/flags_demo_runtime.dart | 4 ---- examples/simple_example/lib/main.dart | 1 - examples/simple_example/lib/screens/flags_screen.dart | 1 - 3 files changed, 6 deletions(-) diff --git a/examples/simple_example/lib/flags/flags_demo_runtime.dart b/examples/simple_example/lib/flags/flags_demo_runtime.dart index dc22afb20..a1a745e25 100644 --- a/examples/simple_example/lib/flags/flags_demo_runtime.dart +++ b/examples/simple_example/lib/flags/flags_demo_runtime.dart @@ -18,12 +18,10 @@ const _countRequests = bool.fromEnvironment('FLAGS_COUNT_REQUESTS', defaultValue: true); class FlagsDemoRuntime { - final String mode; final FlagsRequestCounter? counter; final DatadogFlagsConfiguration configuration; const FlagsDemoRuntime._({ - required this.mode, required this.counter, required this.configuration, }); @@ -38,7 +36,6 @@ class FlagsDemoRuntime { String? siteName, String? applicationId, }) async { - const mode = String.fromEnvironment('FLAGS_MODE', defaultValue: 'live'); final externalFlagsEndpoint = _uriFromEnvironment(_externalFlagsEndpoint); final externalExposureEndpoint = _uriFromEnvironment(_externalExposureEndpoint); @@ -49,7 +46,6 @@ class FlagsDemoRuntime { final counter = _countRequests ? ForwardingFlagsCounter.create() : null; return FlagsDemoRuntime._( - mode: mode, counter: counter, configuration: DatadogFlagsConfiguration( customFlagsEndpoint: externalFlagsEndpoint ?? diff --git a/examples/simple_example/lib/main.dart b/examples/simple_example/lib/main.dart index 1c6a3ec40..893fdb045 100644 --- a/examples/simple_example/lib/main.dart +++ b/examples/simple_example/lib/main.dart @@ -17,7 +17,6 @@ import 'flags/flags_demo_runtime.dart'; import 'url_strategy_stub.dart' if (dart.library.html) 'url_strategy_web.dart'; const graphQlUrl = 'http://localhost:3000/graphql'; -const flagsMode = String.fromEnvironment('FLAGS_MODE', defaultValue: 'live'); const ddClientToken = String.fromEnvironment('DD_CLIENT_TOKEN'); const ddApplicationId = String.fromEnvironment('DD_APPLICATION_ID'); const ddEnv = String.fromEnvironment('DD_ENV'); diff --git a/examples/simple_example/lib/screens/flags_screen.dart b/examples/simple_example/lib/screens/flags_screen.dart index da221d9cd..acd1c0481 100644 --- a/examples/simple_example/lib/screens/flags_screen.dart +++ b/examples/simple_example/lib/screens/flags_screen.dart @@ -161,7 +161,6 @@ class _FlagsScreenState extends State { body: ListView( padding: const EdgeInsets.all(16), children: [ - _Row(label: 'Mode', value: widget.runtime.mode), _Row(label: 'Assignments', value: _assignmentState), const _Row(label: 'Targeting key', value: _targetingKey), _Row( From 9e3a406a1329e6fdd5f4af131bc4334b431e29a4 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 21:18:24 -0400 Subject: [PATCH 08/16] Document staging flags dogfood command --- packages/datadog_flags/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/datadog_flags/README.md b/packages/datadog_flags/README.md index c1fd496ea..8481ba2fe 100644 --- a/packages/datadog_flags/README.md +++ b/packages/datadog_flags/README.md @@ -73,7 +73,7 @@ request counters: cd ../../examples/simple_example flutter run -d \ --dart-define DD_SITE=datad0g.com \ - --dart-define DD_ENV=dev \ + --dart-define DD_ENV=staging \ --dart-define DD_CLIENT_TOKEN= \ --dart-define FLAGS_COUNT_REQUESTS=true \ --dart-define FLAGS_STRING_KEYS=ffe-dogfooding-string-flag From d968ed525c287136d1d841b31aba851f8e1feeb7 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 21:23:32 -0400 Subject: [PATCH 09/16] Show all dogfood flag values --- .../lib/screens/flags_screen.dart | 76 +++++++++++++++---- packages/datadog_flags/README.md | 3 +- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/examples/simple_example/lib/screens/flags_screen.dart b/examples/simple_example/lib/screens/flags_screen.dart index acd1c0481..848134be9 100644 --- a/examples/simple_example/lib/screens/flags_screen.dart +++ b/examples/simple_example/lib/screens/flags_screen.dart @@ -86,7 +86,8 @@ class _FlagsScreenState extends State { void _evaluate() { final flags = <_EvaluatedFlag>[]; - for (final key in _keys(_booleanKeys, const [])) { + for (final key + in _keys(_booleanKeys, const ['ffe-dogfooding-boolean-flag'])) { flags.add(_EvaluatedFlag( label: 'Boolean', key: key, @@ -107,7 +108,8 @@ class _FlagsScreenState extends State { ), )); } - for (final key in _keys(_integerKeys, const [])) { + for (final key + in _keys(_integerKeys, const ['ffe-dogfooding-integer-flag'])) { flags.add(_EvaluatedFlag( label: 'Integer', key: key, @@ -117,9 +119,9 @@ class _FlagsScreenState extends State { ), )); } - for (final key in _keys(_doubleKeys, const [])) { + for (final key in _keys(_doubleKeys, const ['ffe-dogfooding-float-flag'])) { flags.add(_EvaluatedFlag( - label: 'Double', + label: 'Float', key: key, details: _client.getDoubleDetails( key: key, @@ -127,9 +129,9 @@ class _FlagsScreenState extends State { ), )); } - for (final key in _keys(_objectKeys, const [])) { + for (final key in _keys(_objectKeys, const ['ffe-dogfooding-json-flag'])) { flags.add(_EvaluatedFlag( - label: 'Object', + label: 'JSON', key: key, details: _client.getObjectDetails( key: key, @@ -193,7 +195,8 @@ class _FlagsScreenState extends State { const SizedBox(height: 16), for (final flag in _flags) _DetailsRow( - label: '${flag.label}\n${flag.key}', + label: flag.label, + keyName: flag.key, details: flag.details, ), const SizedBox(height: 16), @@ -256,20 +259,65 @@ class _EvaluatedFlag { class _DetailsRow extends StatelessWidget { final String label; + final String keyName; final FlagDetails? details; - const _DetailsRow({required this.label, required this.details}); + const _DetailsRow({ + required this.label, + required this.keyName, + required this.details, + }); @override Widget build(BuildContext context) { final value = details; - return _Row( - label: label, - value: value == null - ? '-' - : '${value.value} | variant=${value.variant ?? '-'} | error=${value.error?.name ?? '-'}', + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 90, + child: Text( + label, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + keyName, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + value == null ? '-' : _formatValue(value.value), + softWrap: true, + ), + const SizedBox(height: 4), + Text( + value == null + ? 'variant=- | error=-' + : 'variant=${value.variant ?? '-'} | error=${value.error?.name ?? '-'}', + style: Theme.of(context).textTheme.bodySmall, + softWrap: true, + ), + ], + ), + ), + ], + ), ); } + + String _formatValue(Object? value) { + if (value is Map || value is List) { + return jsonEncode(value); + } + return '$value'; + } } class _Row extends StatelessWidget { @@ -287,7 +335,7 @@ class _Row extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - width: 150, + width: 190, child: Text( label, style: const TextStyle(fontWeight: FontWeight.w600), diff --git a/packages/datadog_flags/README.md b/packages/datadog_flags/README.md index 8481ba2fe..432ce18e7 100644 --- a/packages/datadog_flags/README.md +++ b/packages/datadog_flags/README.md @@ -75,6 +75,5 @@ flutter run -d \ --dart-define DD_SITE=datad0g.com \ --dart-define DD_ENV=staging \ --dart-define DD_CLIENT_TOKEN= \ - --dart-define FLAGS_COUNT_REQUESTS=true \ - --dart-define FLAGS_STRING_KEYS=ffe-dogfooding-string-flag + --dart-define FLAGS_COUNT_REQUESTS=true ``` From 365b64a68ea522ca26b07c813dd94b698f656319 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 21:39:46 -0400 Subject: [PATCH 10/16] Compact dogfood flags screen --- .../lib/screens/flags_screen.dart | 31 +++++-------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/examples/simple_example/lib/screens/flags_screen.dart b/examples/simple_example/lib/screens/flags_screen.dart index 848134be9..885598518 100644 --- a/examples/simple_example/lib/screens/flags_screen.dart +++ b/examples/simple_example/lib/screens/flags_screen.dart @@ -171,26 +171,11 @@ class _FlagsScreenState extends State { valueKey: const Key('flags-recorded-evaluation-count'), ), if (counter != null) ...[ - _Row( - label: 'Precompute requests', - value: '${counter.precomputeRequestCount}', - valueKey: const Key('flags-precompute-request-count'), - ), _Row( label: 'Exposures', value: '${counter.exposureCount}', valueKey: const Key('flags-exposure-count'), ), - _Row( - label: 'Flageval batches sent', - value: '${counter.evaluationRequestCount}', - valueKey: const Key('flags-evaluation-request-count'), - ), - _Row( - label: 'Flageval events sent', - value: '${counter.evaluationEventCount}', - valueKey: const Key('flags-evaluation-event-count'), - ), ], const SizedBox(height: 16), for (final flag in _flags) @@ -296,14 +281,14 @@ class _DetailsRow extends StatelessWidget { value == null ? '-' : _formatValue(value.value), softWrap: true, ), - const SizedBox(height: 4), - Text( - value == null - ? 'variant=- | error=-' - : 'variant=${value.variant ?? '-'} | error=${value.error?.name ?? '-'}', - style: Theme.of(context).textTheme.bodySmall, - softWrap: true, - ), + if (value?.error != null) ...[ + const SizedBox(height: 2), + Text( + 'error=${value?.error?.name}', + style: Theme.of(context).textTheme.bodySmall, + softWrap: true, + ), + ], ], ), ), From cebd99de90b26e9837c4cbcbc4b219a27664a7a7 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 21:41:49 -0400 Subject: [PATCH 11/16] Place dogfood actions side by side --- .../lib/screens/flags_screen.dart | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/examples/simple_example/lib/screens/flags_screen.dart b/examples/simple_example/lib/screens/flags_screen.dart index 885598518..9e288c2c6 100644 --- a/examples/simple_example/lib/screens/flags_screen.dart +++ b/examples/simple_example/lib/screens/flags_screen.dart @@ -185,16 +185,20 @@ class _FlagsScreenState extends State { details: flag.details, ), const SizedBox(height: 16), - Wrap( - spacing: 12, + Row( children: [ - ElevatedButton( - onPressed: _refreshFlags, - child: const Text('Refresh assignments'), + Expanded( + child: ElevatedButton( + onPressed: _refreshFlags, + child: const Text('Refresh assignments'), + ), ), - ElevatedButton( - onPressed: _evaluate, - child: const Text('Evaluate flags'), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: _evaluate, + child: const Text('Evaluate flags'), + ), ), ], ), From ab5a58137e7bfb9b1889622c044147aa60a683fd Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 21:52:18 -0400 Subject: [PATCH 12/16] Add feature flagging codeowners --- .github/CODEOWNERS | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bf2727e43..ba516e591 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,3 +8,10 @@ doc/ @DataDog/documentation @DataDog/rum-mobile docs/ @DataDog/documentation @DataDog/rum-mobile *README.md @DataDog/documentation @DataDog/rum-mobile *CONTRIBUTING.md @DataDog/documentation @DataDog/rum-mobile + +## Feature Flagging + +packages/datadog_flags/ @DataDog/rum-mobile @DataDog/feature-flagging-and-experimentation-sdk +packages/datadog_flags/README.md @DataDog/documentation @DataDog/rum-mobile @DataDog/feature-flagging-and-experimentation-sdk +examples/simple_example/lib/flags/ @DataDog/rum-mobile @DataDog/feature-flagging-and-experimentation-sdk +examples/simple_example/lib/screens/flags_screen.dart @DataDog/rum-mobile @DataDog/feature-flagging-and-experimentation-sdk From b8641ae5c1ae25289912e7231f73174b641d082d Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 21:56:27 -0400 Subject: [PATCH 13/16] Add datadog_flags license --- packages/datadog_flags/LICENSE | 202 ++++++++++++++++++++++++++++++++- 1 file changed, 201 insertions(+), 1 deletion(-) diff --git a/packages/datadog_flags/LICENSE b/packages/datadog_flags/LICENSE index ba75c69f7..88b9fd307 100644 --- a/packages/datadog_flags/LICENSE +++ b/packages/datadog_flags/LICENSE @@ -1 +1,201 @@ -TODO: Add your license here. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 Datadog, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file From aed5c908e1d6f00f35f4971ee1722cc9178d0261 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 22:17:33 -0400 Subject: [PATCH 14/16] Tighten datadog_flags API and validation setup --- .../android/app/src/main/AndroidManifest.xml | 2 +- .../main/res/xml/network_security_config.xml | 8 + packages/datadog_flags/README.md | 4 +- packages/datadog_flags/analysis_options.yaml | 10 + .../datadog_flags/lib/src/datadog_flags.dart | 3 +- .../lib/src/default_flags_client.dart | 265 ++++++++++++++++++ .../datadog_flags/lib/src/flags_client.dart | 210 ++------------ .../datadog_flags/lib/src/json_value.dart | 6 +- packages/datadog_flags/pubspec.yaml | 4 - .../test/datadog_flags_test.dart | 3 +- .../test/fixtures/precomputed_cases.dart | 254 +++++++++++++++++ .../test/precomputed_fixture_test.dart | 19 +- 12 files changed, 568 insertions(+), 220 deletions(-) create mode 100644 examples/simple_example/android/app/src/main/res/xml/network_security_config.xml create mode 100644 packages/datadog_flags/lib/src/default_flags_client.dart create mode 100644 packages/datadog_flags/test/fixtures/precomputed_cases.dart diff --git a/examples/simple_example/android/app/src/main/AndroidManifest.xml b/examples/simple_example/android/app/src/main/AndroidManifest.xml index de1653362..c438fb62d 100644 --- a/examples/simple_example/android/app/src/main/AndroidManifest.xml +++ b/examples/simple_example/android/app/src/main/AndroidManifest.xml @@ -4,7 +4,7 @@ android:label="test_app" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" - android:usesCleartextTraffic="true"> + android:networkSecurityConfig="@xml/network_security_config"> + + + localhost + 127.0.0.1 + 10.0.2.2 + + diff --git a/packages/datadog_flags/README.md b/packages/datadog_flags/README.md index 432ce18e7..cb4735014 100644 --- a/packages/datadog_flags/README.md +++ b/packages/datadog_flags/README.md @@ -62,8 +62,8 @@ From this package: ```bash dart analyze . -flutter test test -flutter test --platform chrome test +flutter test --no-pub test +flutter test --no-pub --platform chrome test ``` The example app includes a live dogfood screen with optional forwarding diff --git a/packages/datadog_flags/analysis_options.yaml b/packages/datadog_flags/analysis_options.yaml index a5744c1cf..981b33304 100644 --- a/packages/datadog_flags/analysis_options.yaml +++ b/packages/datadog_flags/analysis_options.yaml @@ -2,3 +2,13 @@ include: package:flutter_lints/flutter.yaml # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options + +analyzer: + language: + strict-raw-types: true + +linter: + rules: + prefer_single_quotes: true + prefer_relative_imports: true + unawaited_futures: true diff --git a/packages/datadog_flags/lib/src/datadog_flags.dart b/packages/datadog_flags/lib/src/datadog_flags.dart index c0f1b3544..c96af817e 100644 --- a/packages/datadog_flags/lib/src/datadog_flags.dart +++ b/packages/datadog_flags/lib/src/datadog_flags.dart @@ -8,6 +8,7 @@ import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'datadog_context.dart'; +import 'default_flags_client.dart'; import 'evaluation_aggregator.dart'; import 'exposure_logger.dart'; import 'flag_assignments_fetcher.dart'; @@ -86,7 +87,7 @@ class DatadogFlags { httpClient: runtime.httpClient, ); - final client = DatadogFlagsClient( + final client = DefaultDatadogFlagsClient( name: name, repository: repository, exposureLogger: exposureLogger, diff --git a/packages/datadog_flags/lib/src/default_flags_client.dart b/packages/datadog_flags/lib/src/default_flags_client.dart new file mode 100644 index 000000000..87cbe65c5 --- /dev/null +++ b/packages/datadog_flags/lib/src/default_flags_client.dart @@ -0,0 +1,265 @@ +// 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 'assignment.dart'; +import 'evaluation_aggregator.dart'; +import 'exposure_logger.dart'; +import 'flags_client.dart'; +import 'flags_context.dart'; +import 'flags_details.dart'; +import 'flags_error.dart'; +import 'flags_repository.dart'; +import 'json_value.dart'; +import 'rum_flag_evaluation_reporter.dart'; + +class DefaultDatadogFlagsClient implements DatadogFlagsClient { + @override + final String name; + final FlagsRepository _repository; + final ExposureLogger _exposureLogger; + final EvaluationAggregator _evaluationAggregator; + final RumFlagEvaluationReporter _rumFlagEvaluationReporter; + + DefaultDatadogFlagsClient({ + required this.name, + required FlagsRepository repository, + required ExposureLogger exposureLogger, + required EvaluationAggregator evaluationAggregator, + required RumFlagEvaluationReporter rumFlagEvaluationReporter, + }) : _repository = repository, + _exposureLogger = exposureLogger, + _evaluationAggregator = evaluationAggregator, + _rumFlagEvaluationReporter = rumFlagEvaluationReporter; + + @override + Future setEvaluationContext( + DatadogFlagsEvaluationContext context, + ) async { + await _repository.setEvaluationContext(context); + } + + @override + FlagDetails getBooleanDetails({ + required String key, + required bool defaultValue, + }) { + return _getDetails( + key: key, + defaultValue: defaultValue, + requestedType: FlagVariationType.boolean, + ); + } + + @override + bool getBooleanValue({ + required String key, + required bool defaultValue, + }) { + return getBooleanDetails(key: key, defaultValue: defaultValue).value; + } + + @override + FlagDetails getStringDetails({ + required String key, + required String defaultValue, + }) { + return _getDetails( + key: key, + defaultValue: defaultValue, + requestedType: FlagVariationType.string, + ); + } + + @override + String getStringValue({ + required String key, + required String defaultValue, + }) { + return getStringDetails(key: key, defaultValue: defaultValue).value; + } + + @override + FlagDetails getIntegerDetails({ + required String key, + required int defaultValue, + }) { + return _getDetails( + key: key, + defaultValue: defaultValue, + requestedType: FlagVariationType.integer, + ); + } + + @override + int getIntegerValue({ + required String key, + required int defaultValue, + }) { + return getIntegerDetails(key: key, defaultValue: defaultValue).value; + } + + @override + FlagDetails getDoubleDetails({ + required String key, + required double defaultValue, + }) { + return _getDetails( + key: key, + defaultValue: defaultValue, + requestedType: FlagVariationType.float, + ); + } + + @override + double getDoubleValue({ + required String key, + required double defaultValue, + }) { + return getDoubleDetails(key: key, defaultValue: defaultValue).value; + } + + @override + FlagDetails getObjectDetails({ + required String key, + required Object? defaultValue, + }) { + return _getDetails( + key: key, + defaultValue: sanitizeJsonValue(defaultValue), + requestedType: FlagVariationType.object, + ); + } + + @override + Object? getObjectValue({ + required String key, + required Object? defaultValue, + }) { + return getObjectDetails(key: key, defaultValue: defaultValue).value; + } + + @override + Future flush() { + return _evaluationAggregator.flush(); + } + + @override + Future reset() async { + _evaluationAggregator.dispose(); + await _repository.reset(); + } + + @override + Future dispose() async { + _evaluationAggregator.dispose(); + } + + FlagDetails _getDetails({ + required String key, + required T defaultValue, + required FlagVariationType requestedType, + }) { + final context = _repository.context; + if (context == null) { + _evaluationAggregator.recordEvaluation( + flagKey: key, + assignment: FlagAssignment.defaultAssignment, + evaluationContext: DatadogFlagsEvaluationContext.empty, + error: _EvaluationErrorCode.providerNotReady, + ); + return FlagDetails( + key: key, + value: defaultValue, + error: FlagEvaluationError.providerNotReady, + ); + } + + final assignment = _repository.flagAssignment(key); + if (assignment == null) { + _evaluationAggregator.recordEvaluation( + flagKey: key, + assignment: FlagAssignment.defaultAssignment, + evaluationContext: context, + error: _EvaluationErrorCode.flagNotFound, + ); + return FlagDetails( + key: key, + value: defaultValue, + error: FlagEvaluationError.flagNotFound, + ); + } + + if (requestedType == FlagVariationType.object && + assignment.variationType != FlagVariationType.object) { + _evaluationAggregator.recordEvaluation( + flagKey: key, + assignment: assignment, + evaluationContext: context, + error: _EvaluationErrorCode.typeMismatch, + ); + return FlagDetails( + key: key, + value: defaultValue, + error: FlagEvaluationError.typeMismatch, + ); + } + + final typedValue = assignment.typedValue(requestedType); + if (typedValue == null && requestedType != FlagVariationType.object) { + _evaluationAggregator.recordEvaluation( + flagKey: key, + assignment: assignment, + evaluationContext: context, + error: _EvaluationErrorCode.typeMismatch, + ); + return FlagDetails( + key: key, + value: defaultValue, + error: FlagEvaluationError.typeMismatch, + ); + } + + final value = typedValue as T; + _trackEvaluation(key, assignment, value, context); + return FlagDetails( + key: key, + value: value, + variant: assignment.variationKey, + reason: assignment.reason, + ); + } + + void _trackEvaluation( + String key, + FlagAssignment assignment, + T value, + DatadogFlagsEvaluationContext context, + ) { + unawaited(_exposureLogger.logExposure( + flagKey: key, + assignment: assignment, + evaluationContext: context, + )); + _evaluationAggregator.recordEvaluation( + flagKey: key, + assignment: assignment, + evaluationContext: context, + error: null, + ); + if (value != null) { + _rumFlagEvaluationReporter.report(key, value as Object); + } + } +} + +class _EvaluationErrorCode { + static const providerNotReady = 'PROVIDER_NOT_READY'; + static const flagNotFound = 'FLAG_NOT_FOUND'; + static const typeMismatch = 'TYPE_MISMATCH'; + + _EvaluationErrorCode._(); +} diff --git a/packages/datadog_flags/lib/src/flags_client.dart b/packages/datadog_flags/lib/src/flags_client.dart index 7bf31bb2d..70507370c 100644 --- a/packages/datadog_flags/lib/src/flags_client.dart +++ b/packages/datadog_flags/lib/src/flags_client.dart @@ -3,38 +3,14 @@ // developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import 'dart:async'; - -import 'assignment.dart'; import 'datadog_flags.dart'; -import 'evaluation_aggregator.dart'; -import 'exposure_logger.dart'; import 'flags_context.dart'; import 'flags_details.dart'; -import 'flags_error.dart'; -import 'flags_repository.dart'; -import 'json_value.dart'; -import 'rum_flag_evaluation_reporter.dart'; -class DatadogFlagsClient { +abstract interface class DatadogFlagsClient { static const defaultName = 'default'; - final String name; - final FlagsRepository _repository; - final ExposureLogger _exposureLogger; - final EvaluationAggregator _evaluationAggregator; - final RumFlagEvaluationReporter _rumFlagEvaluationReporter; - - DatadogFlagsClient({ - required this.name, - required FlagsRepository repository, - required ExposureLogger exposureLogger, - required EvaluationAggregator evaluationAggregator, - required RumFlagEvaluationReporter rumFlagEvaluationReporter, - }) : _repository = repository, - _exposureLogger = exposureLogger, - _evaluationAggregator = evaluationAggregator, - _rumFlagEvaluationReporter = rumFlagEvaluationReporter; + String get name; static Future create({ String name = defaultName, @@ -50,215 +26,61 @@ class DatadogFlagsClient { Future setEvaluationContext( DatadogFlagsEvaluationContext context, - ) async { - await _repository.setEvaluationContext(context); - } + ); FlagDetails getBooleanDetails({ required String key, required bool defaultValue, - }) { - return _getDetails( - key: key, - defaultValue: defaultValue, - requestedType: FlagVariationType.boolean, - ); - } + }); bool getBooleanValue({ required String key, required bool defaultValue, - }) { - return getBooleanDetails(key: key, defaultValue: defaultValue).value; - } + }); FlagDetails getStringDetails({ required String key, required String defaultValue, - }) { - return _getDetails( - key: key, - defaultValue: defaultValue, - requestedType: FlagVariationType.string, - ); - } + }); String getStringValue({ required String key, required String defaultValue, - }) { - return getStringDetails(key: key, defaultValue: defaultValue).value; - } + }); FlagDetails getIntegerDetails({ required String key, required int defaultValue, - }) { - return _getDetails( - key: key, - defaultValue: defaultValue, - requestedType: FlagVariationType.integer, - ); - } + }); int getIntegerValue({ required String key, required int defaultValue, - }) { - return getIntegerDetails(key: key, defaultValue: defaultValue).value; - } + }); FlagDetails getDoubleDetails({ required String key, required double defaultValue, - }) { - return _getDetails( - key: key, - defaultValue: defaultValue, - requestedType: FlagVariationType.float, - ); - } + }); double getDoubleValue({ required String key, required double defaultValue, - }) { - return getDoubleDetails(key: key, defaultValue: defaultValue).value; - } + }); FlagDetails getObjectDetails({ required String key, required Object? defaultValue, - }) { - return _getDetails( - key: key, - defaultValue: sanitizeJsonValue(defaultValue), - requestedType: FlagVariationType.object, - ); - } + }); Object? getObjectValue({ required String key, required Object? defaultValue, - }) { - return getObjectDetails(key: key, defaultValue: defaultValue).value; - } - - Future flush() { - return _evaluationAggregator.flush(); - } - - Future reset() async { - _evaluationAggregator.dispose(); - await _repository.reset(); - } - - Future dispose() async { - _evaluationAggregator.dispose(); - } - - FlagDetails _getDetails({ - required String key, - required T defaultValue, - required FlagVariationType requestedType, - }) { - final context = _repository.context; - if (context == null) { - _evaluationAggregator.recordEvaluation( - flagKey: key, - assignment: FlagAssignment.defaultAssignment, - evaluationContext: DatadogFlagsEvaluationContext.empty, - error: EvaluationErrorCode.providerNotReady, - ); - return FlagDetails( - key: key, - value: defaultValue, - error: FlagEvaluationError.providerNotReady, - ); - } - - final assignment = _repository.flagAssignment(key); - if (assignment == null) { - _evaluationAggregator.recordEvaluation( - flagKey: key, - assignment: FlagAssignment.defaultAssignment, - evaluationContext: context, - error: EvaluationErrorCode.flagNotFound, - ); - return FlagDetails( - key: key, - value: defaultValue, - error: FlagEvaluationError.flagNotFound, - ); - } - - if (requestedType == FlagVariationType.object && - assignment.variationType != FlagVariationType.object) { - _evaluationAggregator.recordEvaluation( - flagKey: key, - assignment: assignment, - evaluationContext: context, - error: EvaluationErrorCode.typeMismatch, - ); - return FlagDetails( - key: key, - value: defaultValue, - error: FlagEvaluationError.typeMismatch, - ); - } - - final typedValue = assignment.typedValue(requestedType); - if (typedValue == null && requestedType != FlagVariationType.object) { - _evaluationAggregator.recordEvaluation( - flagKey: key, - assignment: assignment, - evaluationContext: context, - error: EvaluationErrorCode.typeMismatch, - ); - return FlagDetails( - key: key, - value: defaultValue, - error: FlagEvaluationError.typeMismatch, - ); - } + }); - final value = typedValue as T; - _trackEvaluation(key, assignment, value, context); - return FlagDetails( - key: key, - value: value, - variant: assignment.variationKey, - reason: assignment.reason, - ); - } - - void _trackEvaluation( - String key, - FlagAssignment assignment, - T value, - DatadogFlagsEvaluationContext context, - ) { - unawaited(_exposureLogger.logExposure( - flagKey: key, - assignment: assignment, - evaluationContext: context, - )); - _evaluationAggregator.recordEvaluation( - flagKey: key, - assignment: assignment, - evaluationContext: context, - error: null, - ); - if (value != null) { - _rumFlagEvaluationReporter.report(key, value as Object); - } - } -} + Future flush(); -class EvaluationErrorCode { - static const providerNotReady = 'PROVIDER_NOT_READY'; - static const flagNotFound = 'FLAG_NOT_FOUND'; - static const typeMismatch = 'TYPE_MISMATCH'; + Future reset(); - EvaluationErrorCode._(); + Future dispose(); } diff --git a/packages/datadog_flags/lib/src/json_value.dart b/packages/datadog_flags/lib/src/json_value.dart index ed7ab5c35..71218416d 100644 --- a/packages/datadog_flags/lib/src/json_value.dart +++ b/packages/datadog_flags/lib/src/json_value.dart @@ -14,7 +14,7 @@ Object? sanitizeJsonValue(Object? value) { if (value is num) { return value.toDouble(); } - if (value is Map) { + if (value is Map) { return value.map((key, value) { if (key is! String) { throw ArgumentError.value( @@ -23,7 +23,7 @@ Object? sanitizeJsonValue(Object? value) { return MapEntry(key, sanitizeJsonValue(value)); }); } - if (value is Iterable) { + if (value is Iterable) { return value.map(sanitizeJsonValue).toList(); } throw ArgumentError.value(value, 'value', 'Unsupported JSON value'); @@ -36,7 +36,7 @@ Object? sortedJson(Object? value) { for (final key in sortedKeys) key: sortedJson(value[key]), }; } - if (value is List) { + if (value is List) { return value.map(sortedJson).toList(); } return value; diff --git a/packages/datadog_flags/pubspec.yaml b/packages/datadog_flags/pubspec.yaml index f7bbb04f2..5dcb2b917 100644 --- a/packages/datadog_flags/pubspec.yaml +++ b/packages/datadog_flags/pubspec.yaml @@ -20,7 +20,3 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^2.0.1 - -flutter: - assets: - - test/fixtures/precomputed/cases/ diff --git a/packages/datadog_flags/test/datadog_flags_test.dart b/packages/datadog_flags/test/datadog_flags_test.dart index e9c57f49f..2c06b83df 100644 --- a/packages/datadog_flags/test/datadog_flags_test.dart +++ b/packages/datadog_flags/test/datadog_flags_test.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:datadog_flags/datadog_flags.dart'; +import 'package:datadog_flags/src/default_flags_client.dart'; import 'package:datadog_flags/src/evaluation_aggregator.dart'; import 'package:datadog_flags/src/exposure_logger.dart'; import 'package:datadog_flags/src/flag_assignments_fetcher.dart'; @@ -402,7 +403,7 @@ void main() { ); await repository.restore(); final fakeRum = FakeRumFlagEvaluationReporter(); - final client = DatadogFlagsClient( + final client = DefaultDatadogFlagsClient( name: 'rum-test', repository: repository, exposureLogger: ExposureLogger( diff --git a/packages/datadog_flags/test/fixtures/precomputed_cases.dart b/packages/datadog_flags/test/fixtures/precomputed_cases.dart new file mode 100644 index 000000000..a4c034598 --- /dev/null +++ b/packages/datadog_flags/test/fixtures/precomputed_cases.dart @@ -0,0 +1,254 @@ +// 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. + +class PrecomputedFixtureCase { + final String name; + final String json; + + const PrecomputedFixtureCase({ + required this.name, + required this.json, + }); +} + +const precomputedFixtureCases = [ + PrecomputedFixtureCase( + name: 'all-types-success.json', + json: r''' +{ + "name": "all-types-success", + "description": "Successful precomputed assignments for all supported Flutter value types.", + "context": { + "targetingKey": "precomputed-user", + "attributes": { + "plan": "pro", + "platform": "flutter" + } + }, + "response": { + "data": { + "attributes": { + "flags": { + "flutter.fixture.enabled": { + "allocationKey": "allocation-success", + "variationKey": "enabled", + "variationType": "boolean", + "variationValue": true, + "reason": "TARGETING_MATCH", + "doLog": true + }, + "flutter.fixture.title": { + "allocationKey": "allocation-success", + "variationKey": "copy-a", + "variationType": "string", + "variationValue": "Datadog Flags", + "reason": "TARGETING_MATCH", + "doLog": true + }, + "flutter.fixture.limit": { + "allocationKey": "allocation-success", + "variationKey": "limit-five", + "variationType": "integer", + "variationValue": 5, + "reason": "TARGETING_MATCH", + "doLog": true + }, + "flutter.fixture.ratio": { + "allocationKey": "allocation-success", + "variationKey": "half", + "variationType": "float", + "variationValue": 0.5, + "reason": "TARGETING_MATCH", + "doLog": true + }, + "flutter.fixture.config": { + "allocationKey": "allocation-success", + "variationKey": "object-a", + "variationType": "object", + "variationValue": { + "showBanner": true, + "colors": [ + "blue", + "green" + ] + }, + "reason": "TARGETING_MATCH", + "doLog": true + } + } + } + } + }, + "evaluations": [ + { + "flag": "flutter.fixture.enabled", + "variationType": "boolean", + "defaultValue": false, + "result": { + "value": true, + "variant": "enabled", + "reason": "TARGETING_MATCH", + "error": null + } + }, + { + "flag": "flutter.fixture.title", + "variationType": "string", + "defaultValue": "Fallback title", + "result": { + "value": "Datadog Flags", + "variant": "copy-a", + "reason": "TARGETING_MATCH", + "error": null + } + }, + { + "flag": "flutter.fixture.limit", + "variationType": "integer", + "defaultValue": 0, + "result": { + "value": 5, + "variant": "limit-five", + "reason": "TARGETING_MATCH", + "error": null + } + }, + { + "flag": "flutter.fixture.ratio", + "variationType": "float", + "defaultValue": 0.0, + "result": { + "value": 0.5, + "variant": "half", + "reason": "TARGETING_MATCH", + "error": null + } + }, + { + "flag": "flutter.fixture.config", + "variationType": "object", + "defaultValue": {}, + "result": { + "value": { + "showBanner": true, + "colors": [ + "blue", + "green" + ] + }, + "variant": "object-a", + "reason": "TARGETING_MATCH", + "error": null + } + } + ], + "expectedEmissions": { + "exposures": 5, + "flagevaluationRequests": 1, + "flagevaluationEvents": 5 + } +} +''', + ), + PrecomputedFixtureCase( + name: 'defaults-and-emission-gates.json', + json: r''' +{ + "name": "defaults-and-emission-gates", + "description": "Precomputed assignments that validate exposure gates, unknown variation isolation, and default/error flagevaluation payloads.", + "context": { + "targetingKey": "precomputed-user", + "attributes": { + "plan": "free", + "platform": "flutter" + } + }, + "response": { + "data": { + "attributes": { + "flags": { + "flutter.fixture.silent": { + "allocationKey": "allocation-silent", + "variationKey": "silent-on", + "variationType": "boolean", + "variationValue": true, + "reason": "TARGETING_MATCH", + "doLog": false + }, + "flutter.fixture.string": { + "allocationKey": "allocation-string", + "variationKey": "copy-b", + "variationType": "string", + "variationValue": "Actual string", + "reason": "TARGETING_MATCH", + "doLog": true + }, + "flutter.fixture.unknown": { + "allocationKey": "allocation-unknown", + "variationKey": "unknown-a", + "variationType": "unsupported", + "variationValue": "Ignored", + "reason": "TARGETING_MATCH", + "doLog": true + } + } + } + } + }, + "evaluations": [ + { + "flag": "flutter.fixture.silent", + "variationType": "boolean", + "defaultValue": false, + "result": { + "value": true, + "variant": "silent-on", + "reason": "TARGETING_MATCH", + "error": null + } + }, + { + "flag": "flutter.fixture.string", + "variationType": "boolean", + "defaultValue": false, + "result": { + "value": false, + "variant": null, + "reason": null, + "error": "typeMismatch" + } + }, + { + "flag": "flutter.fixture.unknown", + "variationType": "boolean", + "defaultValue": false, + "result": { + "value": false, + "variant": null, + "reason": null, + "error": "flagNotFound" + } + }, + { + "flag": "flutter.fixture.missing", + "variationType": "string", + "defaultValue": "fallback", + "result": { + "value": "fallback", + "variant": null, + "reason": null, + "error": "flagNotFound" + } + } + ], + "expectedEmissions": { + "exposures": 0, + "flagevaluationRequests": 1, + "flagevaluationEvents": 4 + } +} +''', + ), +]; diff --git a/packages/datadog_flags/test/precomputed_fixture_test.dart b/packages/datadog_flags/test/precomputed_fixture_test.dart index 537fe3871..2d77c01e6 100644 --- a/packages/datadog_flags/test/precomputed_fixture_test.dart +++ b/packages/datadog_flags/test/precomputed_fixture_test.dart @@ -7,20 +7,13 @@ import 'dart:convert'; import 'package:datadog_flags/datadog_flags.dart'; import 'package:datadog_flutter_plugin/datadog_flutter_plugin.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const fixtureAssets = [ - 'test/fixtures/precomputed/cases/all-types-success.json', - 'test/fixtures/precomputed/cases/defaults-and-emission-gates.json', - ]; +import 'fixtures/precomputed_cases.dart'; +void main() { setUp(() async { await DatadogFlags.disable(); }); @@ -29,12 +22,11 @@ void main() { await DatadogFlags.disable(); }); - for (final fixtureAsset in fixtureAssets) { + for (final fixtureCase in precomputedFixtureCases) { test( - 'precomputed fixture ${fixtureAsset.split('/').last}', + 'precomputed fixture ${fixtureCase.name}', () async { - final fixture = jsonDecode(await rootBundle.loadString(fixtureAsset)) - as Map; + final fixture = jsonDecode(fixtureCase.json) as Map; final requests = []; final httpClient = MockClient((request) async { requests.add(request); @@ -121,7 +113,6 @@ void main() { await DatadogFlags.disable(); } }, - skip: kIsWeb ? 'Fixture asset-file loading is validated on VM.' : false, ); } } From e936b8250464c04ead5f26013d2ce134b727bd8c Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 22:19:53 -0400 Subject: [PATCH 15/16] Assert flags EVP intake requests --- .../test/datadog_flags_test.dart | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/datadog_flags/test/datadog_flags_test.dart b/packages/datadog_flags/test/datadog_flags_test.dart index 2c06b83df..5ac2d5f9f 100644 --- a/packages/datadog_flags/test/datadog_flags_test.dart +++ b/packages/datadog_flags/test/datadog_flags_test.dart @@ -478,7 +478,17 @@ void main() { await Future.delayed(Duration.zero); expect(exposureRequests(), hasLength(1)); - final exposure = jsonDecode(exposureRequests().single.body); + final request = exposureRequests().single; + expect( + request.url.toString(), + 'https://browser-intake-datadoghq.com/api/v2/exposures?ddsource=flutter', + ); + expect(request.headers['Content-Type'], 'text/plain;charset=UTF-8'); + expect(request.headers['DD-API-KEY'], 'client-token'); + expect(request.headers['DD-EVP-ORIGIN'], 'flutter'); + expect(request.headers['DD-EVP-ORIGIN-VERSION'], '9.8.7'); + + final exposure = jsonDecode(request.body); expect(exposure['flag'], {'key': 'show-paywall'}); expect(exposure['allocation'], {'key': 'allocation-a'}); expect(exposure['variant'], {'key': 'enabled'}); @@ -523,6 +533,16 @@ void main() { await client.flush(); expect(evaluationRequests(), hasLength(1)); + final request = evaluationRequests().single; + expect( + request.url.toString(), + 'https://browser-intake-datadoghq.com/api/v2/flagevaluation?ddsource=flutter', + ); + expect(request.headers['Content-Type'], 'application/json'); + expect(request.headers['DD-API-KEY'], 'client-token'); + expect(request.headers['DD-EVP-ORIGIN'], 'flutter'); + expect(request.headers['DD-EVP-ORIGIN-VERSION'], '9.8.7'); + final batches = evaluationRequests().map((request) { return jsonDecode(request.body) as Map; }).toList(); From b841a3ca16d8b1d9b558d18ab0ef165fce0812b2 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 22:29:44 -0400 Subject: [PATCH 16/16] Add RUM context to flag events --- .../lib/src/datadog_context.dart | 20 ++++++++------ .../lib/src/datadog_event_context.dart | 26 +++++++++++++++++++ .../lib/src/evaluation_aggregator.dart | 21 ++++++++++----- .../lib/src/exposure_logger.dart | 7 +++-- .../test/datadog_flags_test.dart | 22 +++++++++++++++- 5 files changed, 79 insertions(+), 17 deletions(-) create mode 100644 packages/datadog_flags/lib/src/datadog_event_context.dart diff --git a/packages/datadog_flags/lib/src/datadog_context.dart b/packages/datadog_flags/lib/src/datadog_context.dart index 625584611..3bdf1d0a3 100644 --- a/packages/datadog_flags/lib/src/datadog_context.dart +++ b/packages/datadog_flags/lib/src/datadog_context.dart @@ -71,13 +71,6 @@ class DatadogFlagsContext { } Map evaluationBatchContext() { - final rumContext = applicationId == null - ? null - : { - 'application': {'id': applicationId}, - 'view': null, - }; - return removeNullValues({ 'geo': null, 'device': { @@ -93,11 +86,22 @@ class DatadogFlagsContext { 'service': service ?? '', 'version': version ?? '', 'env': env, - 'rum': rumContext, + 'rum': _rumContext(applicationId), }); } } +Map? _rumContext(String? applicationId) { + if (applicationId == null) { + return null; + } + + return { + 'application': {'id': applicationId}, + 'view': null, + }; +} + String _deviceTypeForTargetPlatform(TargetPlatform platform) { return switch (platform) { TargetPlatform.android || TargetPlatform.iOS => 'mobile', diff --git a/packages/datadog_flags/lib/src/datadog_event_context.dart b/packages/datadog_flags/lib/src/datadog_event_context.dart new file mode 100644 index 000000000..1316117da --- /dev/null +++ b/packages/datadog_flags/lib/src/datadog_event_context.dart @@ -0,0 +1,26 @@ +// 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 'datadog_context.dart'; + +Map? rumContextFor(DatadogFlagsContext context) { + final applicationId = context.applicationId; + if (applicationId == null) { + return null; + } + + return { + 'application': {'id': applicationId}, + 'view': null, + }; +} + +Map? ddContextFor(DatadogFlagsContext context) { + final ddContext = removeNullValues({ + 'service': context.service, + 'rum': rumContextFor(context), + }); + return ddContext.isEmpty ? null : ddContext; +} diff --git a/packages/datadog_flags/lib/src/evaluation_aggregator.dart b/packages/datadog_flags/lib/src/evaluation_aggregator.dart index f54c19ba4..e0f971def 100644 --- a/packages/datadog_flags/lib/src/evaluation_aggregator.dart +++ b/packages/datadog_flags/lib/src/evaluation_aggregator.dart @@ -11,6 +11,7 @@ import 'package:uuid/uuid.dart'; import 'assignment.dart'; import 'datadog_context.dart'; +import 'datadog_event_context.dart'; import 'flags_configuration.dart'; import 'flags_context.dart'; import 'json_value.dart'; @@ -46,10 +47,12 @@ class EvaluationAggregator { } final now = configuration.dateProvider().millisecondsSinceEpoch; + final ddContext = ddContextFor(datadogContext); final key = _aggregationKey( flagKey: flagKey, assignment: assignment, evaluationContext: evaluationContext, + ddContext: ddContext, error: error, ); final existing = _aggregations[key]; @@ -67,6 +70,7 @@ class EvaluationAggregator { targetingKey: evaluationContext.targetingKey, error: error, attributes: evaluationContext.attributes, + ddContext: ddContext, firstEvaluation: now, lastEvaluation: now, evaluationCount: 1, @@ -137,6 +141,7 @@ class EvaluationAggregator { targetingKey: evaluation.targetingKey, attributes: evaluation.attributes, ), + ddContext: evaluation.ddContext, error: evaluation.error, )] = evaluation; } @@ -147,6 +152,7 @@ String _aggregationKey({ required String flagKey, required FlagAssignment assignment, required DatadogFlagsEvaluationContext evaluationContext, + required Map? ddContext, required String? error, }) { return jsonEncode({ @@ -156,6 +162,7 @@ String _aggregationKey({ 'targetingKey': evaluationContext.targetingKey, 'error': error, 'context': sortedJson(evaluationContext.attributes), + 'dd': sortedJson(ddContext), }); } @@ -166,6 +173,7 @@ class _AggregatedEvaluation { final String targetingKey; final String? error; final Map attributes; + final Map? ddContext; final int firstEvaluation; int lastEvaluation; int evaluationCount; @@ -178,6 +186,7 @@ class _AggregatedEvaluation { required this.targetingKey, required this.error, required this.attributes, + required this.ddContext, required this.firstEvaluation, required this.lastEvaluation, required this.evaluationCount, @@ -185,6 +194,11 @@ class _AggregatedEvaluation { }); Map toJson() { + final eventContext = removeNullValues({ + 'evaluation': attributes.isEmpty ? null : sanitizeJsonValue(attributes), + 'dd': ddContext, + }); + return removeNullValues({ 'timestamp': firstEvaluation, 'flag': {'key': flagKey}, @@ -197,12 +211,7 @@ class _AggregatedEvaluation { 'targeting_key': targetingKey, 'runtime_default_used': runtimeDefaultUsed ? true : null, 'error': error == null ? null : {'message': error}, - 'context': attributes.isEmpty - ? null - : { - 'evaluation': sanitizeJsonValue(attributes), - 'dd': null, - }, + 'context': eventContext.isEmpty ? null : eventContext, }); } } diff --git a/packages/datadog_flags/lib/src/exposure_logger.dart b/packages/datadog_flags/lib/src/exposure_logger.dart index 7cf540e9f..879ff9707 100644 --- a/packages/datadog_flags/lib/src/exposure_logger.dart +++ b/packages/datadog_flags/lib/src/exposure_logger.dart @@ -10,6 +10,7 @@ import 'package:uuid/uuid.dart'; import 'assignment.dart'; import 'datadog_context.dart'; +import 'datadog_event_context.dart'; import 'flags_configuration.dart'; import 'flags_context.dart'; import 'json_value.dart'; @@ -50,8 +51,10 @@ class ExposureLogger { path: '/api/v2/exposures', queryParameters: {'ddsource': datadogContext.source}, ); - final event = { + final event = removeNullValues({ 'timestamp': configuration.dateProvider().millisecondsSinceEpoch, + 'service': datadogContext.service, + 'rum': rumContextFor(datadogContext), 'allocation': {'key': assignment.allocationKey}, 'flag': {'key': flagKey}, 'variant': {'key': assignment.variationKey}, @@ -59,7 +62,7 @@ class ExposureLogger { 'id': evaluationContext.targetingKey, 'attributes': sanitizeJsonValue(evaluationContext.attributes), }, - }; + }); try { final response = await httpClient.post( diff --git a/packages/datadog_flags/test/datadog_flags_test.dart b/packages/datadog_flags/test/datadog_flags_test.dart index 5ac2d5f9f..36c8aeb15 100644 --- a/packages/datadog_flags/test/datadog_flags_test.dart +++ b/packages/datadog_flags/test/datadog_flags_test.dart @@ -489,6 +489,11 @@ void main() { expect(request.headers['DD-EVP-ORIGIN-VERSION'], '9.8.7'); final exposure = jsonDecode(request.body); + expect(exposure['service'], 'flutter-example'); + expect(exposure['rum'], { + 'application': {'id': 'rum-app-id'}, + 'view': null, + }); expect(exposure['flag'], {'key': 'show-paywall'}); expect(exposure['allocation'], {'key': 'allocation-a'}); expect(exposure['variant'], {'key': 'enabled'}); @@ -564,7 +569,13 @@ void main() { expect(success['runtime_default_used'], isNull); expect(success['context'], { 'evaluation': {'plan': 'pro'}, - 'dd': null, + 'dd': { + 'service': 'flutter-example', + 'rum': { + 'application': {'id': 'rum-app-id'}, + 'view': null, + }, + }, }); final providerNotReady = evaluations.singleWhere((evaluation) { @@ -574,6 +585,15 @@ void main() { expect(providerNotReady['runtime_default_used'], isTrue); expect(providerNotReady['variant'], isNull); expect(providerNotReady['allocation'], isNull); + expect(providerNotReady['context'], { + 'dd': { + 'service': 'flutter-example', + 'rum': { + 'application': {'id': 'rum-app-id'}, + 'view': null, + }, + }, + }); final typeMismatch = evaluations.singleWhere((evaluation) { return ((evaluation['error'] as Map?)?['message']) ==