diff --git a/.github/workflows/contributor-workflow.yml b/.github/workflows/contributor-workflow.yml index 1aabe94..541fb08 100644 --- a/.github/workflows/contributor-workflow.yml +++ b/.github/workflows/contributor-workflow.yml @@ -113,4 +113,4 @@ jobs: dart-server-sdk-openfeature/info_report.txt dart-server-sdk-openfeature/warning_report.txt dart-server-sdk-openfeature/error_report.txt - retention-days: 7 + retention-days: 7 \ No newline at end of file diff --git a/.github/workflows/notifications.yml b/.github/workflows/notifications.yml index 8e3470d..040de79 100644 --- a/.github/workflows/notifications.yml +++ b/.github/workflows/notifications.yml @@ -1,7 +1,6 @@ name: Notifications on: - pull_request: push: branches: - main diff --git a/.gitignore b/.gitignore index 05d0295..1801d87 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ .dart_tool/ -pubspec.lock +**/pubspec.lock diff --git a/README.md b/README.md index 72e3372..f8598cc 100644 --- a/README.md +++ b/README.md @@ -26,17 +26,17 @@
- - Pub Version + + Pub Version - + API Reference Code Coverage - - GitHub CI Status + + GitHub CI Status

@@ -68,15 +68,15 @@ See [TBD](TBD) for the complete API documentation. | Status | Features | Description | | ------ |---------------------------------| --------------------------------------------------------------------------------------------------------------------------------- | -| ❌ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. | -| ❌ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). | -| ❌ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | -| ❌ | [Logging](#logging) | Integrate with popular logging packages. | -| ❌ | [Domains](#domains) | Logically bind clients with providers.| -| ❌ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | -| ❌ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | -| ❌ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) | -| ❌ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | +| ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. | +| ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). | +| ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | +| ✅ | [Logging](#logging) | Integrate with popular logging packages. | +| ✅ | [Domains](#domains) | Logically bind clients with providers.| +| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | +| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | +| ✅ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) | +| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌ diff --git a/lib/hooks.dart b/lib/hooks.dart index 7dd6d9d..1a3166c 100644 --- a/lib/hooks.dart +++ b/lib/hooks.dart @@ -1,5 +1,6 @@ -// Hook interface and default hooks. +// Hook interface with OpenTelemetry support import 'dart:async'; +import 'dart:convert'; /// Defines the stages in the hook lifecycle /// Used internally by the hook manager for execution ordering @@ -7,7 +8,7 @@ enum HookStage { BEFORE, // Before flag evaluation AFTER, // After successful evaluation ERROR, // When an error occurs - FINALLY // Always executed last + FINALLY, // Always executed last } /// Hook priority levels @@ -15,7 +16,7 @@ enum HookPriority { CRITICAL, // Highest priority, executes first HIGH, // High priority NORMAL, // Default priority - LOW // Lowest priority + LOW, // Lowest priority } /// Configuration for hook behavior @@ -46,6 +47,25 @@ class HookMetadata { }); } +/// Details about the evaluation result +class EvaluationDetails { + final String flagKey; + final dynamic value; + final String? variant; + final String reason; + final DateTime evaluationTime; + final Map? additionalDetails; + + EvaluationDetails({ + required this.flagKey, + required this.value, + this.variant, + this.reason = 'DEFAULT', + required this.evaluationTime, + this.additionalDetails, + }); +} + /// Context passed to hooks during execution class HookContext { final String flagKey; @@ -63,6 +83,13 @@ class HookContext { }); } +/// Optional hints for hook execution +class HookHints { + final Map hints; + + const HookHints({this.hints = const {}}); +} + /// Interface for implementing hooks abstract class Hook { /// Hook metadata and configuration @@ -77,8 +104,12 @@ abstract class Hook { /// When an error occurs Future error(HookContext context); - /// Always executed at the end - Future finally_(HookContext context); + /// Always executed at the end, now with evaluation details parameter + Future finally_( + HookContext context, + EvaluationDetails? evaluationDetails, [ + HookHints? hints, + ]); } /// Manager for hook registration and execution @@ -90,8 +121,8 @@ class HookManager { HookManager({ bool failFast = false, Duration defaultTimeout = const Duration(seconds: 5), - }) : _failFast = failFast, - _defaultTimeout = defaultTimeout; + }) : _failFast = failFast, + _defaultTimeout = defaultTimeout; /// Register a new hook void addHook(Hook hook) { @@ -106,6 +137,8 @@ class HookManager { Map? context, { dynamic result, Exception? error, + EvaluationDetails? evaluationDetails, + HookHints? hints, }) async { final hookContext = HookContext( flagKey: flagKey, @@ -121,6 +154,8 @@ class HookManager { stage, hookContext, hook.metadata.config.timeout, + evaluationDetails, + hints, ); } catch (e) { if (_failFast || !hook.metadata.config.continueOnError) { @@ -133,8 +168,9 @@ class HookManager { /// Sort hooks by priority void _sortHooks() { - _hooks.sort((a, b) => - a.metadata.priority.index.compareTo(b.metadata.priority.index)); + _hooks.sort( + (a, b) => a.metadata.priority.index.compareTo(b.metadata.priority.index), + ); } /// Execute a single hook with timeout @@ -143,6 +179,8 @@ class HookManager { HookStage stage, HookContext context, Duration? timeout, + EvaluationDetails? evaluationDetails, + HookHints? hints, ) async { final effectiveTimeout = timeout ?? _defaultTimeout; @@ -158,7 +196,7 @@ class HookManager { hookExecution = hook.error(context); break; case HookStage.FINALLY: - hookExecution = hook.finally_(context); + hookExecution = hook.finally_(context, evaluationDetails, hints); break; } @@ -172,3 +210,269 @@ class HookManager { ); } } + +/// A base hook implementation with empty methods +abstract class BaseHook implements Hook { + @override + final HookMetadata metadata; + + BaseHook({required this.metadata}); + + @override + Future before(HookContext context) async {} + + @override + Future after(HookContext context) async {} + + @override + Future error(HookContext context) async {} + + @override + Future finally_( + HookContext context, + EvaluationDetails? evaluationDetails, [ + HookHints? hints, + ]) async {} +} + +// +// OpenTelemetry Support +// + +/// OpenTelemetry semantic conventions for feature flags +/// Based on https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-spans/ +class OTelFeatureFlagConstants { + /// Common attributes + static const String FEATURE_FLAG = 'feature_flag'; + static const String FLAG_KEY = 'feature_flag.key'; + static const String FLAG_PROVIDER_NAME = 'feature_flag.provider_name'; + static const String FLAG_VARIANT = 'feature_flag.variant'; + + /// Value type specific attributes + static const String FLAG_EVALUATED = 'feature_flag.evaluated'; + static const String FLAG_VALUE_TYPE = 'feature_flag.value_type'; + static const String FLAG_VALUE_BOOLEAN = 'feature_flag.value.boolean'; + static const String FLAG_VALUE_STRING = 'feature_flag.value.string'; + static const String FLAG_VALUE_INT = 'feature_flag.value.int'; + static const String FLAG_VALUE_FLOAT = 'feature_flag.value.float'; + + /// Reason constants + static const String REASON = 'feature_flag.reason'; + static const String REASON_DEFAULT = 'DEFAULT'; + static const String REASON_TARGETING_MATCH = 'TARGETING_MATCH'; + static const String REASON_SPLIT = 'SPLIT'; + static const String REASON_CACHED = 'CACHED'; + static const String REASON_ERROR = 'ERROR'; + static const String REASON_DISABLED = 'DISABLED'; + static const String REASON_UNKNOWN = 'UNKNOWN'; + + /// Value types + static const String TYPE_BOOLEAN = 'BOOLEAN'; + static const String TYPE_STRING = 'STRING'; + static const String TYPE_INT = 'INTEGER'; + static const String TYPE_DOUBLE = 'FLOAT'; + static const String TYPE_OBJECT = 'OBJECT'; +} + +/// Represents an OpenTelemetry attribute +class OTelAttribute { + final String key; + final dynamic value; + + const OTelAttribute(this.key, this.value); + + Map toJson() => {key: value}; +} + +/// A collection of OpenTelemetry attributes +class OTelAttributes { + final List attributes; + + const OTelAttributes(this.attributes); + + Map toJson() { + final result = {}; + for (final attr in attributes) { + result.addAll(attr.toJson()); + } + return result; + } + + @override + String toString() => jsonEncode(toJson()); +} + +/// Utility class for generating OpenTelemetry-compatible telemetry from feature flag evaluations +class OpenTelemetryUtil { + /// Creates OpenTelemetry-compatible attributes for a feature flag evaluation + /// + /// These attributes follow the OpenTelemetry semantic conventions for feature flags. + /// They can be used with any OpenTelemetry-compatible telemetry system. + static OTelAttributes createOTelAttributes({ + required String flagKey, + required dynamic value, + String? providerName, + String? variant, + String reason = OTelFeatureFlagConstants.REASON_DEFAULT, + Map? evaluationContext, + }) { + final attributes = []; + + // Add common attributes + attributes.add(OTelAttribute(OTelFeatureFlagConstants.FLAG_KEY, flagKey)); + attributes.add( + OTelAttribute(OTelFeatureFlagConstants.FLAG_EVALUATED, true), + ); + + if (providerName != null && providerName.isNotEmpty) { + attributes.add( + OTelAttribute( + OTelFeatureFlagConstants.FLAG_PROVIDER_NAME, + providerName, + ), + ); + } + + if (variant != null && variant.isNotEmpty) { + attributes.add( + OTelAttribute(OTelFeatureFlagConstants.FLAG_VARIANT, variant), + ); + } + + attributes.add(OTelAttribute(OTelFeatureFlagConstants.REASON, reason)); + + // Add value and determine type + if (value != null) { + if (value is bool) { + attributes.add( + OTelAttribute( + OTelFeatureFlagConstants.FLAG_VALUE_TYPE, + OTelFeatureFlagConstants.TYPE_BOOLEAN, + ), + ); + attributes.add( + OTelAttribute(OTelFeatureFlagConstants.FLAG_VALUE_BOOLEAN, value), + ); + } else if (value is String) { + attributes.add( + OTelAttribute( + OTelFeatureFlagConstants.FLAG_VALUE_TYPE, + OTelFeatureFlagConstants.TYPE_STRING, + ), + ); + attributes.add( + OTelAttribute(OTelFeatureFlagConstants.FLAG_VALUE_STRING, value), + ); + } else if (value is int) { + attributes.add( + OTelAttribute( + OTelFeatureFlagConstants.FLAG_VALUE_TYPE, + OTelFeatureFlagConstants.TYPE_INT, + ), + ); + attributes.add( + OTelAttribute(OTelFeatureFlagConstants.FLAG_VALUE_INT, value), + ); + } else if (value is double) { + attributes.add( + OTelAttribute( + OTelFeatureFlagConstants.FLAG_VALUE_TYPE, + OTelFeatureFlagConstants.TYPE_DOUBLE, + ), + ); + attributes.add( + OTelAttribute(OTelFeatureFlagConstants.FLAG_VALUE_FLOAT, value), + ); + } else { + attributes.add( + OTelAttribute( + OTelFeatureFlagConstants.FLAG_VALUE_TYPE, + OTelFeatureFlagConstants.TYPE_OBJECT, + ), + ); + // For non-primitive types, we convert to a string representation + attributes.add( + OTelAttribute( + OTelFeatureFlagConstants.FLAG_VALUE_STRING, + value.toString(), + ), + ); + } + } + + return OTelAttributes(attributes); + } + + /// Creates OpenTelemetry-compatible attributes from EvaluationDetails + /// + /// This is a convenience method for use in hooks, especially the finally_ hook. + static OTelAttributes fromEvaluationDetails( + EvaluationDetails details, { + String? providerName, + }) { + return createOTelAttributes( + flagKey: details.flagKey, + value: details.value, + providerName: providerName, + variant: details.variant, + reason: details.reason, + ); + } + + /// Creates OpenTelemetry-compatible attributes from HookContext + /// + /// This is a convenience method for use in hooks when EvaluationDetails is not available. + static OTelAttributes fromHookContext( + HookContext context, { + String? providerName, + String? reason, + }) { + return createOTelAttributes( + flagKey: context.flagKey, + value: context.result, + providerName: providerName, + reason: + reason ?? + (context.error != null + ? OTelFeatureFlagConstants.REASON_ERROR + : OTelFeatureFlagConstants.REASON_DEFAULT), + evaluationContext: context.evaluationContext, + ); + } +} + +/// A hook that generates OpenTelemetry-compatible telemetry +class OpenTelemetryHook extends BaseHook { + final String providerName; + final void Function(OTelAttributes)? telemetryCallback; + + OpenTelemetryHook({ + required this.providerName, + this.telemetryCallback, + HookPriority priority = HookPriority.NORMAL, + }) : super( + metadata: HookMetadata(name: 'OpenTelemetryHook', priority: priority), + ); + + @override + Future finally_( + HookContext context, + EvaluationDetails? evaluationDetails, [ + HookHints? hints, + ]) async { + // Generate OpenTelemetry attributes + final otelAttributes = + evaluationDetails != null + ? OpenTelemetryUtil.fromEvaluationDetails( + evaluationDetails, + providerName: providerName, + ) + : OpenTelemetryUtil.fromHookContext( + context, + providerName: providerName, + ); + + // Call the telemetry callback if provided + telemetryCallback?.call(otelAttributes); + } +} diff --git a/pubspec.lock b/pubspec.lock deleted file mode 100644 index 3358167..0000000 --- a/pubspec.lock +++ /dev/null @@ -1,474 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" - url: "https://pub.dev" - source: hosted - version: "76.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" - url: "https://pub.dev" - source: hosted - version: "6.11.0" - args: - dependency: transitive - description: - name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 - url: "https://pub.dev" - source: hosted - version: "2.6.0" - async: - dependency: transitive - description: - name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 - url: "https://pub.dev" - source: hosted - version: "2.12.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb - url: "https://pub.dev" - source: hosted - version: "8.9.2" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" - url: "https://pub.dev" - source: hosted - version: "4.10.1" - collection: - dependency: "direct main" - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - coverage: - dependency: transitive - description: - name: coverage - sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43 - url: "https://pub.dev" - source: hosted - version: "1.11.1" - crypto: - dependency: transitive - description: - name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" - url: "https://pub.dev" - source: hosted - version: "3.0.6" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" - url: "https://pub.dev" - source: hosted - version: "2.3.7" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" - git_hooks: - dependency: "direct dev" - description: - name: git_hooks - sha256: f379143e5c710057e78a49b6016b000661ddd07aa0e56df9a7def3bbff5a98e9 - url: "https://pub.dev" - source: hosted - version: "1.0.2" - glob: - dependency: transitive - description: - name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "76d306a1c3afb33fe82e2bbacad62a61f409b5634c915fceb0d799de1a913360" - url: "https://pub.dev" - source: hosted - version: "4.1.1" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf - url: "https://pub.dev" - source: hosted - version: "0.7.1" - lints: - dependency: "direct dev" - description: - name: lints - sha256: "4a16b3f03741e1252fda5de3ce712666d010ba2122f8e912c94f9f7b90e1a4c3" - url: "https://pub.dev" - source: hosted - version: "5.1.0" - logging: - dependency: "direct main" - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - macros: - dependency: transitive - description: - name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.dev" - source: hosted - version: "0.1.3-main.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb - url: "https://pub.dev" - source: hosted - version: "0.12.16+1" - meta: - dependency: "direct main" - description: - name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c - url: "https://pub.dev" - source: hosted - version: "1.16.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mockito: - dependency: "direct dev" - description: - name: mockito - sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" - url: "https://pub.dev" - source: hosted - version: "5.4.4" - node_preamble: - dependency: transitive - description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - package_config: - dependency: transitive - description: - name: package_config - sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - pool: - dependency: transitive - description: - name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" - url: "https://pub.dev" - source: hosted - version: "1.5.1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 - url: "https://pub.dev" - source: hosted - version: "1.1.3" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 - url: "https://pub.dev" - source: hosted - version: "2.0.1" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b - url: "https://pub.dev" - source: hosted - version: "2.1.2" - source_maps: - dependency: transitive - description: - name: source_maps - sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" - url: "https://pub.dev" - source: hosted - version: "0.10.13" - source_span: - dependency: transitive - description: - name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" - url: "https://pub.dev" - source: hosted - version: "1.10.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" - url: "https://pub.dev" - source: hosted - version: "1.12.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 - url: "https://pub.dev" - source: hosted - version: "2.1.2" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test: - dependency: "direct dev" - description: - name: test - sha256: "22eb7769bee38c7e032d532e8daa2e1cc901b799f603550a4db8f3a5f5173ea2" - url: "https://pub.dev" - source: hosted - version: "1.25.12" - test_api: - dependency: transitive - description: - name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd - url: "https://pub.dev" - source: hosted - version: "0.7.4" - test_core: - dependency: transitive - description: - name: test_core - sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" - url: "https://pub.dev" - source: hosted - version: "0.6.8" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 - url: "https://pub.dev" - source: hosted - version: "15.0.0" - watcher: - dependency: transitive - description: - name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web: - dependency: transitive - description: - name: web - sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb - url: "https://pub.dev" - source: hosted - version: "1.1.0" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" - url: "https://pub.dev" - source: hosted - version: "0.1.6" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" - url: "https://pub.dev" - source: hosted - version: "3.0.1" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" - url: "https://pub.dev" - source: hosted - version: "3.1.2" -sdks: - dart: ">=3.7.2 <4.0.0" diff --git a/test.txt b/test.txt deleted file mode 100644 index 7581981..0000000 --- a/test.txt +++ /dev/null @@ -1 +0,0 @@ -Testing for pipeline trigger. Add content to test \ No newline at end of file diff --git a/test/hooks_test.dart b/test/hooks_test.dart index f54d8fc..e0b03ea 100644 --- a/test/hooks_test.dart +++ b/test/hooks_test.dart @@ -5,14 +5,13 @@ import '../lib/hooks.dart'; class TestHook implements Hook { final List executionOrder = []; final HookPriority _priority; + bool receivedEvaluationDetails = false; TestHook([this._priority = HookPriority.NORMAL]); @override - HookMetadata get metadata => HookMetadata( - name: 'TestHook', - priority: _priority, - ); + HookMetadata get metadata => + HookMetadata(name: 'TestHook', priority: _priority); @override Future before(HookContext context) async { @@ -30,46 +29,49 @@ class TestHook implements Hook { } @override - Future finally_(HookContext context) async { + Future finally_( + HookContext context, + EvaluationDetails? evaluationDetails, [ + HookHints? hints, + ]) async { executionOrder.add('finally'); + if (evaluationDetails != null) { + receivedEvaluationDetails = true; + } + if (hints != null) { + executionOrder.add('with_hints'); + } } } -class ErrorHook implements Hook { - @override - HookMetadata get metadata => HookMetadata(name: 'ErrorHook'); - - @override - Future before(HookContext context) async { - throw Exception('Test error'); - } - - @override - Future after(HookContext context) async {} - @override - Future error(HookContext context) async {} - @override - Future finally_(HookContext context) async {} -} +class OTelTestHook extends OpenTelemetryHook { + final List> capturedAttributes = []; -class SlowHook implements Hook { - @override - HookMetadata get metadata => HookMetadata( - name: 'SlowHook', - config: HookConfig(timeout: Duration(milliseconds: 100)), + OTelTestHook({required String providerName}) + : super( + providerName: providerName, + telemetryCallback: null, // We'll override the finally_ method ); @override - Future before(HookContext context) async { - await Future.delayed(Duration(milliseconds: 200)); - } + Future finally_( + HookContext context, + EvaluationDetails? evaluationDetails, [ + HookHints? hints, + ]) async { + final otelAttributes = + evaluationDetails != null + ? OpenTelemetryUtil.fromEvaluationDetails( + evaluationDetails, + providerName: providerName, + ) + : OpenTelemetryUtil.fromHookContext( + context, + providerName: providerName, + ); - @override - Future after(HookContext context) async {} - @override - Future error(HookContext context) async {} - @override - Future finally_(HookContext context) async {} + capturedAttributes.add(otelAttributes.toJson()); + } } void main() { @@ -84,67 +86,204 @@ void main() { }); test('executes hooks in correct order', () async { - await manager.executeHooks( - HookStage.BEFORE, - 'test-flag', - {'user': 'test'}, + await manager.executeHooks(HookStage.BEFORE, 'test-flag', { + 'user': 'test', + }); + + await manager.executeHooks(HookStage.AFTER, 'test-flag', { + 'user': 'test', + }, result: true); + + await manager.executeHooks(HookStage.FINALLY, 'test-flag', { + 'user': 'test', + }); + + expect(testHook.executionOrder, ['before', 'after', 'finally']); + }); + + test('passes evaluation details to finally hook', () async { + final evaluationDetails = EvaluationDetails( + flagKey: 'test-flag', + value: true, + evaluationTime: DateTime.now(), + reason: 'TARGETING_MATCH', + variant: 'control', ); - await manager.executeHooks( - HookStage.AFTER, - 'test-flag', - {'user': 'test'}, - result: true, + await manager.executeHooks(HookStage.FINALLY, 'test-flag', { + 'user': 'test', + }, evaluationDetails: evaluationDetails); + + expect(testHook.receivedEvaluationDetails, isTrue); + }); + + test('passes hook hints to finally hook', () async { + final hints = HookHints(hints: {'source': 'test'}); + + await manager.executeHooks(HookStage.FINALLY, 'test-flag', { + 'user': 'test', + }, hints: hints); + + expect(testHook.executionOrder, contains('with_hints')); + }); + }); + + group('OpenTelemetryUtil', () { + test('creates attributes for boolean flag', () { + final attributes = OpenTelemetryUtil.createOTelAttributes( + flagKey: 'test-flag', + value: true, + providerName: 'test-provider', + ); + + final jsonMap = attributes.toJson(); + + expect(jsonMap[OTelFeatureFlagConstants.FLAG_KEY], equals('test-flag')); + expect( + jsonMap[OTelFeatureFlagConstants.FLAG_PROVIDER_NAME], + equals('test-provider'), + ); + expect(jsonMap[OTelFeatureFlagConstants.FLAG_EVALUATED], isTrue); + expect( + jsonMap[OTelFeatureFlagConstants.FLAG_VALUE_TYPE], + equals(OTelFeatureFlagConstants.TYPE_BOOLEAN), + ); + expect(jsonMap[OTelFeatureFlagConstants.FLAG_VALUE_BOOLEAN], isTrue); + expect( + jsonMap[OTelFeatureFlagConstants.REASON], + equals(OTelFeatureFlagConstants.REASON_DEFAULT), ); + }); - await manager.executeHooks( - HookStage.FINALLY, - 'test-flag', - {'user': 'test'}, + test('creates attributes for string flag', () { + final attributes = OpenTelemetryUtil.createOTelAttributes( + flagKey: 'test-flag', + value: 'test-value', + providerName: 'test-provider', + variant: 'control', + reason: OTelFeatureFlagConstants.REASON_TARGETING_MATCH, ); - expect(testHook.executionOrder, ['before', 'after', 'finally']); + final jsonMap = attributes.toJson(); + + expect(jsonMap[OTelFeatureFlagConstants.FLAG_VARIANT], equals('control')); + expect( + jsonMap[OTelFeatureFlagConstants.FLAG_VALUE_TYPE], + equals(OTelFeatureFlagConstants.TYPE_STRING), + ); + expect( + jsonMap[OTelFeatureFlagConstants.FLAG_VALUE_STRING], + equals('test-value'), + ); + expect( + jsonMap[OTelFeatureFlagConstants.REASON], + equals(OTelFeatureFlagConstants.REASON_TARGETING_MATCH), + ); }); - test('handles hook timeouts', () async { - final slowHook = SlowHook(); - manager = HookManager(failFast: true); - manager.addHook(slowHook); + test('creates attributes for numeric flags', () { + final intAttributes = OpenTelemetryUtil.createOTelAttributes( + flagKey: 'int-flag', + value: 42, + providerName: 'test-provider', + ); + + final doubleAttributes = OpenTelemetryUtil.createOTelAttributes( + flagKey: 'double-flag', + value: 3.14, + providerName: 'test-provider', + ); - await expectLater( - manager.executeHooks(HookStage.BEFORE, 'test-flag', {}), - throwsA(isA()), + expect( + intAttributes.toJson()[OTelFeatureFlagConstants.FLAG_VALUE_TYPE], + equals(OTelFeatureFlagConstants.TYPE_INT), + ); + expect( + intAttributes.toJson()[OTelFeatureFlagConstants.FLAG_VALUE_INT], + equals(42), + ); + + expect( + doubleAttributes.toJson()[OTelFeatureFlagConstants.FLAG_VALUE_TYPE], + equals(OTelFeatureFlagConstants.TYPE_DOUBLE), + ); + expect( + doubleAttributes.toJson()[OTelFeatureFlagConstants.FLAG_VALUE_FLOAT], + equals(3.14), ); }); + }); - test('sorts hooks by priority', () async { - final highPriorityHook = TestHook(HookPriority.HIGH); - final lowPriorityHook = TestHook(HookPriority.LOW); + group('OpenTelemetryHook', () { + test('generates telemetry from evaluation details', () async { + final otelHook = OTelTestHook(providerName: 'test-provider'); - manager - ..addHook(lowPriorityHook) - ..addHook(highPriorityHook); + final evaluationDetails = EvaluationDetails( + flagKey: 'test-flag', + value: true, + evaluationTime: DateTime.now(), + variant: 'control', + ); - await manager.executeHooks(HookStage.BEFORE, 'test-flag', {}); + await otelHook.finally_( + HookContext(flagKey: 'test-flag', result: true), + evaluationDetails, + ); + expect(otelHook.capturedAttributes.length, equals(1)); + expect( + otelHook.capturedAttributes[0][OTelFeatureFlagConstants.FLAG_KEY], + equals('test-flag'), + ); expect( - highPriorityHook.executionOrder, - contains('before'), + otelHook.capturedAttributes[0][OTelFeatureFlagConstants + .FLAG_VALUE_BOOLEAN], + isTrue, ); expect( - lowPriorityHook.executionOrder, - contains('before'), + otelHook.capturedAttributes[0][OTelFeatureFlagConstants.FLAG_VARIANT], + equals('control'), ); }); - test('respects failFast setting', () async { - manager = HookManager(failFast: true); - final errorHook = ErrorHook(); - manager.addHook(errorHook); + test( + 'generates telemetry from context when details not available', + () async { + final otelHook = OTelTestHook(providerName: 'test-provider'); + + await otelHook.finally_( + HookContext(flagKey: 'test-flag', result: 'test-value'), + null, + ); + + expect(otelHook.capturedAttributes.length, equals(1)); + expect( + otelHook.capturedAttributes[0][OTelFeatureFlagConstants.FLAG_KEY], + equals('test-flag'), + ); + expect( + otelHook.capturedAttributes[0][OTelFeatureFlagConstants + .FLAG_VALUE_STRING], + equals('test-value'), + ); + }, + ); + + test('includes error reason when appropriate', () async { + final otelHook = OTelTestHook(providerName: 'test-provider'); + + await otelHook.finally_( + HookContext( + flagKey: 'test-flag', + result: null, + error: Exception('Test error'), + ), + null, + ); expect( - () => manager.executeHooks(HookStage.BEFORE, 'test-flag', {}), - throwsException, + otelHook.capturedAttributes[0][OTelFeatureFlagConstants.REASON], + equals(OTelFeatureFlagConstants.REASON_ERROR), ); }); });