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 diff --git a/examples/simple_example/android/app/src/main/AndroidManifest.xml b/examples/simple_example/android/app/src/main/AndroidManifest.xml index 291671a68..c438fb62d 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:networkSecurityConfig="@xml/network_security_config"> + + + localhost + 127.0.0.1 + 10.0.2.2 + + diff --git a/examples/simple_example/android/gradle.properties b/examples/simple_example/android/gradle.properties index 20a8e942a..9707ca724 100644 --- a/examples/simple_example/android/gradle.properties +++ b/examples/simple_example/android/gradle.properties @@ -2,3 +2,7 @@ org.gradle.jvmargs=-Xmx3000M android.useAndroidX=true android.enableJetifier=true kotlin_version=2.1.0 +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/examples/simple_example/ios/Podfile.lock b/examples/simple_example/ios/Podfile.lock index 4260fdbc6..f7d23c21d 100644 --- a/examples/simple_example/ios/Podfile.lock +++ b/examples/simple_example/ios/Podfile.lock @@ -18,19 +18,19 @@ PODS: - DatadogWebViewTracking (~> 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) - KSCrash/Core (2.5.0) @@ -49,6 +49,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 @@ -61,6 +64,7 @@ DEPENDENCIES: - Flutter (from `Flutter`) - 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: @@ -89,26 +93,29 @@ EXTERNAL SOURCES: :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 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..a1a745e25 --- /dev/null +++ b/examples/simple_example/lib/flags/flags_demo_runtime.dart @@ -0,0 +1,117 @@ +// 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 'flags_request_counter.dart'; +import 'forwarding_flags_counter.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 FlagsRequestCounter? counter; + final DatadogFlagsConfiguration configuration; + + const FlagsDemoRuntime._({ + required this.counter, + required this.configuration, + }); + + Future stop() async { + await counter?.stop(); + } + + static Future create({ + String? clientToken, + String? env, + String? siteName, + String? applicationId, + }) async { + final externalFlagsEndpoint = _uriFromEnvironment(_externalFlagsEndpoint); + final externalExposureEndpoint = + _uriFromEnvironment(_externalExposureEndpoint); + final externalEvaluationEndpoint = + _uriFromEnvironment(_externalEvaluationEndpoint); + + final useDatad0g = siteName == 'datad0g.com'; + final counter = _countRequests ? ForwardingFlagsCounter.create() : null; + + return FlagsDemoRuntime._( + counter: counter, + configuration: DatadogFlagsConfiguration( + customFlagsEndpoint: externalFlagsEndpoint ?? + (useDatad0g + ? Uri.https( + 'preview.ff-cdn.datad0g.com', + '/precompute-assignments', + ) + : null), + customExposureEndpoint: externalExposureEndpoint ?? + (useDatad0g + ? Uri.parse( + 'https://browser-intake-datad0g.com/api/v2/exposures?ddsource=flutter', + ) + : null), + customEvaluationEndpoint: externalEvaluationEndpoint ?? + (useDatad0g + ? Uri.parse( + 'https://browser-intake-datad0g.com/api/v2/flagevaluation?ddsource=flutter', + ) + : null), + httpClient: + counter is ForwardingFlagsCounter ? counter.httpClient : null, + datadogContext: _datadogContext( + useDatad0g: useDatad0g, + clientToken: clientToken, + env: env, + applicationId: applicationId, + ), + evaluationFlushInterval: const Duration(seconds: 1), + ), + ); + } +} + +DatadogFlagsContext? _datadogContext({ + required bool useDatad0g, + required String? clientToken, + required String? env, + required String? applicationId, +}) { + 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..e79c1bb9b --- /dev/null +++ b/examples/simple_example/lib/flags/forwarding_flags_counter.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 'flags_request_counter.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(); + } +} + +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/main.dart b/examples/simple_example/lib/main.dart index 3b85f9b67..893fdb045 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,16 @@ 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 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'); -void main() async { +Future main() async { await dotenv.load(); WidgetsFlutterBinding.ensureInitialized(); @@ -24,14 +30,31 @@ void main() async { DatadogSdk.instance.sdkVerbosity = CoreLoggerLevel.debug; + final siteName = _configValue( + 'DD_SITE', + defineValue: ddSite, + defaultValue: 'us1', + ); + final intakeEndpoint = _intakeEndpointForSite(siteName); + final applicationId = _configValue( + 'DD_APPLICATION_ID', + defineValue: ddApplicationId, + defaultValue: '', + ); final datadogConfig = DatadogConfiguration( - clientToken: dotenv.get('DD_CLIENT_TOKEN', fallback: ''), - env: dotenv.get('DD_ENV', fallback: ''), - site: DatadogSite.us1, - loggingConfiguration: DatadogLoggingConfiguration(), + clientToken: _configValue( + 'DD_CLIENT_TOKEN', + defineValue: ddClientToken, + defaultValue: '', + ), + env: _configValue('DD_ENV', defineValue: ddEnv, defaultValue: 'dev'), + site: _siteForName(siteName), + loggingConfiguration: + DatadogLoggingConfiguration(customEndpoint: intakeEndpoint), firstPartyHosts: ['localhost'], rumConfiguration: DatadogRumConfiguration( - applicationId: dotenv.get('DD_APPLICATION_ID', fallback: ''), + applicationId: applicationId, + customEndpoint: intakeEndpoint, traceSampleRate: 100.0, trackResourceHeaders: ResourceHeadersExtractor( captureHeaders: [ @@ -62,10 +85,51 @@ void main() async { // runUsingRunApp(datadogConfig); runUsingAlternativeInit( datadogConfig, + siteName: siteName, ); } -Future runUsingAlternativeInit(DatadogConfiguration datadogConfig) async { +String _configValue( + String name, { + required String defineValue, + required String defaultValue, +}) { + if (defineValue.isNotEmpty) { + return defineValue; + } + final value = dotenv.maybeGet(name); + if (value != null && value.isNotEmpty) { + return value; + } + return defaultValue; +} + +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); @@ -84,6 +148,13 @@ Future runUsingAlternativeInit(DatadogConfiguration datadogConfig) async { }; await DatadogSdk.instance.initialize(datadogConfig, TrackingConsent.granted); + 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)), HttpLink(graphQlUrl), @@ -92,19 +163,36 @@ 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()); - - runApp(MyApp( - graphQLClient: graphQlClient, - )); + // This path is not used by default, but keep flags configured for parity + // if the example is switched back to DatadogSdk.runApp. + final siteName = _configValue( + 'DD_SITE', + defineValue: ddSite, + defaultValue: '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)), + HttpLink(graphQlUrl), + ]); + final graphQlClient = GraphQLClient(link: link, cache: GraphQLCache()); + + 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..9e288c2c6 --- /dev/null +++ b/examples/simple_example/lib/screens/flags_screen.dart @@ -0,0 +1,338 @@ +// 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:flutter/material.dart'; +import 'package:go_router/go_router.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: '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'); + static const _doubleKeys = String.fromEnvironment('FLAGS_DOUBLE_KEYS'); + static const _objectKeys = String.fromEnvironment('FLAGS_OBJECT_KEYS'); + + late final DatadogFlagsClient _client; + 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(() { + _assignmentState = 'fetching'; + }); + try { + await _client.setEvaluationContext(DatadogFlagsEvaluationContext( + targetingKey: _targetingKey, + attributes: _targetingAttributes(), + )); + _evaluate(); + setState(() { + _assignmentState = 'ready'; + }); + } catch (error) { + setState(() { + _assignmentState = 'fetch failed: $error'; + _flags = []; + }); + } + } + + void _evaluate() { + final flags = <_EvaluatedFlag>[]; + for (final key + in _keys(_booleanKeys, const ['ffe-dogfooding-boolean-flag'])) { + flags.add(_EvaluatedFlag( + label: 'Boolean', + key: key, + details: _client.getBooleanDetails( + key: key, + defaultValue: false, + ), + )); + } + for (final key + in _keys(_stringKeys, const ['ffe-dogfooding-string-flag'])) { + flags.add(_EvaluatedFlag( + label: 'String', + key: key, + details: _client.getStringDetails( + key: key, + defaultValue: 'Fallback title', + ), + )); + } + for (final key + in _keys(_integerKeys, const ['ffe-dogfooding-integer-flag'])) { + flags.add(_EvaluatedFlag( + label: 'Integer', + key: key, + details: _client.getIntegerDetails( + key: key, + defaultValue: 0, + ), + )); + } + for (final key in _keys(_doubleKeys, const ['ffe-dogfooding-float-flag'])) { + flags.add(_EvaluatedFlag( + label: 'Float', + key: key, + details: _client.getDoubleDetails( + key: key, + defaultValue: 0, + ), + )); + } + for (final key in _keys(_objectKeys, const ['ffe-dogfooding-json-flag'])) { + flags.add(_EvaluatedFlag( + label: 'JSON', + key: key, + details: _client.getObjectDetails( + key: key, + defaultValue: const {}, + ), + )); + } + setState(() { + _flags = flags; + _recordedEvaluationCount += flags.length; + }); + } + + @override + Widget build(BuildContext context) { + final counter = widget.runtime.counter; + return Scaffold( + 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: [ + _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: 'Exposures', + value: '${counter.exposureCount}', + valueKey: const Key('flags-exposure-count'), + ), + ], + const SizedBox(height: 16), + for (final flag in _flags) + _DetailsRow( + label: flag.label, + keyName: flag.key, + details: flag.details, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: _refreshFlags, + child: const Text('Refresh assignments'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: _evaluate, + child: const Text('Evaluate flags'), + ), + ), + ], + ), + ], + ), + ); + } + + List _keys(String configured, List defaultKeys) { + if (configured.trim().isNotEmpty) { + return configured + .split(',') + .map((key) => key.trim()) + .where((key) => key.isNotEmpty) + .toList(growable: false); + } + 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 { + final String label; + final String key; + final FlagDetails details; + + const _EvaluatedFlag({ + required this.label, + required this.key, + required this.details, + }); +} + +class _DetailsRow extends StatelessWidget { + final String label; + final String keyName; + final FlagDetails? details; + + const _DetailsRow({ + required this.label, + required this.keyName, + required this.details, + }); + + @override + Widget build(BuildContext context) { + final value = details; + 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, + ), + if (value?.error != null) ...[ + const SizedBox(height: 2), + Text( + '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 { + 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: 190, + 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..d4caee55c 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: @@ -276,7 +291,7 @@ packages: source: hosted version: "2.2.3" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f @@ -499,6 +514,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 @@ -556,10 +627,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: @@ -681,5 +752,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..786014733 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,6 +36,8 @@ dependencies: dependency_overrides: datadog_flutter_plugin: path: ../../packages/datadog_flutter_plugin + datadog_flags: + path: ../../packages/datadog_flags dev_dependencies: flutter_test: 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..759a68af8 --- /dev/null +++ b/examples/simple_example/test/flags_request_counter_test.dart @@ -0,0 +1,57 @@ +// 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', + ), + 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/.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..88b9fd307 --- /dev/null +++ b/packages/datadog_flags/LICENSE @@ -0,0 +1,201 @@ + 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 diff --git a/packages/datadog_flags/README.md b/packages/datadog_flags/README.md new file mode 100644 index 000000000..cb4735014 --- /dev/null +++ b/packages/datadog_flags/README.md @@ -0,0 +1,79 @@ +# 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 --no-pub test +flutter test --no-pub --platform chrome test +``` + +The example app includes a live dogfood screen with optional forwarding +request counters: + +```bash +cd ../../examples/simple_example +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 +``` diff --git a/packages/datadog_flags/analysis_options.yaml b/packages/datadog_flags/analysis_options.yaml new file mode 100644 index 000000000..981b33304 --- /dev/null +++ b/packages/datadog_flags/analysis_options.yaml @@ -0,0 +1,14 @@ +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/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..3bdf1d0a3 --- /dev/null +++ b/packages/datadog_flags/lib/src/datadog_context.dart @@ -0,0 +1,118 @@ +// 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() { + 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(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', + 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_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/datadog_flags.dart b/packages/datadog_flags/lib/src/datadog_flags.dart new file mode 100644 index 000000000..c96af817e --- /dev/null +++ b/packages/datadog_flags/lib/src/datadog_flags.dart @@ -0,0 +1,173 @@ +// 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 'default_flags_client.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 = DefaultDatadogFlagsClient( + 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/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/evaluation_aggregator.dart b/packages/datadog_flags/lib/src/evaluation_aggregator.dart new file mode 100644 index 000000000..e0f971def --- /dev/null +++ b/packages/datadog_flags/lib/src/evaluation_aggregator.dart @@ -0,0 +1,217 @@ +// 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 'datadog_event_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 ddContext = ddContextFor(datadogContext); + final key = _aggregationKey( + flagKey: flagKey, + assignment: assignment, + evaluationContext: evaluationContext, + ddContext: ddContext, + 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, + ddContext: ddContext, + 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, + ), + ddContext: evaluation.ddContext, + error: evaluation.error, + )] = evaluation; + } + } +} + +String _aggregationKey({ + required String flagKey, + required FlagAssignment assignment, + required DatadogFlagsEvaluationContext evaluationContext, + required Map? ddContext, + required String? error, +}) { + return jsonEncode({ + 'flagKey': flagKey, + 'variantKey': assignment.variationKey, + 'allocationKey': assignment.allocationKey, + 'targetingKey': evaluationContext.targetingKey, + 'error': error, + 'context': sortedJson(evaluationContext.attributes), + 'dd': sortedJson(ddContext), + }); +} + +class _AggregatedEvaluation { + final String flagKey; + final String variantKey; + final String allocationKey; + final String targetingKey; + final String? error; + final Map attributes; + final Map? ddContext; + 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.ddContext, + required this.firstEvaluation, + required this.lastEvaluation, + required this.evaluationCount, + required this.runtimeDefaultUsed, + }); + + Map toJson() { + final eventContext = removeNullValues({ + 'evaluation': attributes.isEmpty ? null : sanitizeJsonValue(attributes), + 'dd': ddContext, + }); + + 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': eventContext.isEmpty ? null : eventContext, + }); + } +} 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..879ff9707 --- /dev/null +++ b/packages/datadog_flags/lib/src/exposure_logger.dart @@ -0,0 +1,86 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. This product includes software +// developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import 'dart:convert'; + +import 'package:http/http.dart' as http; +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'; + +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 = removeNullValues({ + 'timestamp': configuration.dateProvider().millisecondsSinceEpoch, + 'service': datadogContext.service, + 'rum': rumContextFor(datadogContext), + '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..38483d068 --- /dev/null +++ b/packages/datadog_flags/lib/src/flag_assignments_fetcher.dart @@ -0,0 +1,121 @@ +// 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', + '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': { + 'dd_env': datadogContext.env, + }, + '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..70507370c --- /dev/null +++ b/packages/datadog_flags/lib/src/flags_client.dart @@ -0,0 +1,86 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. This product includes software +// developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import 'datadog_flags.dart'; +import 'flags_context.dart'; +import 'flags_details.dart'; + +abstract interface class DatadogFlagsClient { + static const defaultName = 'default'; + + String get name; + + 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, + ); + + FlagDetails getBooleanDetails({ + required String key, + required bool defaultValue, + }); + + bool getBooleanValue({ + required String key, + required bool defaultValue, + }); + + FlagDetails getStringDetails({ + required String key, + required String defaultValue, + }); + + String getStringValue({ + required String key, + required String defaultValue, + }); + + FlagDetails getIntegerDetails({ + required String key, + required int defaultValue, + }); + + int getIntegerValue({ + required String key, + required int defaultValue, + }); + + FlagDetails getDoubleDetails({ + required String key, + required double defaultValue, + }); + + double getDoubleValue({ + required String key, + required double defaultValue, + }); + + FlagDetails getObjectDetails({ + required String key, + required Object? defaultValue, + }); + + Object? getObjectValue({ + required String key, + required Object? defaultValue, + }); + + Future flush(); + + Future reset(); + + Future dispose(); +} 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..71218416d --- /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..5dcb2b917 --- /dev/null +++ b/packages/datadog_flags/pubspec.yaml @@ -0,0 +1,22 @@ +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 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..36c8aeb15 --- /dev/null +++ b/packages/datadog_flags/test/datadog_flags_test.dart @@ -0,0 +1,660 @@ +// 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/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'; +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(); + } + + 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( + 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.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'], {'dd_env': 'staging'}); + expect(attributes.containsKey('source'), isFalse); + 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 = DefaultDatadogFlagsClient( + 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 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['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'}); + 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 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(); + 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': { + 'service': 'flutter-example', + 'rum': { + 'application': {'id': 'rum-app-id'}, + 'view': 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); + 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']) == + '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); + }); + + 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 { + 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/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 new file mode 100644 index 000000000..2d77c01e6 --- /dev/null +++ b/packages/datadog_flags/test/precomputed_fixture_test.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 'dart:convert'; + +import 'package:datadog_flags/datadog_flags.dart'; +import 'package:datadog_flutter_plugin/datadog_flutter_plugin.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'fixtures/precomputed_cases.dart'; + +void main() { + setUp(() async { + await DatadogFlags.disable(); + }); + + tearDown(() async { + await DatadogFlags.disable(); + }); + + for (final fixtureCase in precomputedFixtureCases) { + test( + 'precomputed fixture ${fixtureCase.name}', + () async { + final fixture = jsonDecode(fixtureCase.json) 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(); + } + }, + ); + } +} + +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',