[FFL-1945] Add flag evaluation metrics via OpenFeature hook#5599
[FFL-1945] Add flag evaluation metrics via OpenFeature hook#5599
Conversation
Typing analysisNote: Ignored files are excluded from the next sections. Untyped methodsThis PR introduces 2 partially typed methods, and clears 1 partially typed method. It increases the percentage of typed methods from 61.33% to 61.6% (+0.27%). Partially typed methods (+2-1)❌ Introduced:If you believe a method or an attribute is rightfully untyped or partially typed, you can add |
🎉 All green!❄️ No new flaky tests detected 🎯 Code Coverage (details) 🔗 Commit SHA: caca4d7 | Docs | Datadog PR Page | Give us feedback! |
8a1f290 to
46c5c56
Compare
BenchmarksBenchmark execution time: 2026-04-25 00:46:12 Comparing candidate commit caca4d7 in PR branch Found 0 performance improvements and 0 performance regressions! Performance is the same for 45 metrics, 1 unstable metrics.
|
a0d96ae to
dca0c3d
Compare
dca0c3d to
576c7e4
Compare
58fbef9 to
ae9c63f
Compare
| metadata = evaluation_details.flag_metadata | ||
| return nil unless metadata.is_a?(Hash) | ||
|
|
||
| metadata['allocation_key'] |
There was a problem hiding this comment.
In dd-trace-py this is a constant (METADATA_ALLOCATION_KEY) that is imported and used in the provider. Importing into Provider in this case isn't as simple, so I opted to use a string literal
ae9c63f to
60aa06e
Compare
| METER_NAME = 'ddtrace.openfeature' | ||
| METRIC_NAME = 'feature_flag.evaluations' | ||
| METRIC_UNIT = '{evaluation}' | ||
| METRIC_DESCRIPTION = 'Number of feature flag evaluations' | ||
|
|
||
| ATTR_FLAG_KEY = 'feature_flag.key' | ||
| ATTR_VARIANT = 'feature_flag.result.variant' | ||
| ATTR_REASON = 'feature_flag.result.reason' | ||
| ATTR_ALLOCATION_KEY = 'feature_flag.result.allocation_key' | ||
| ATTR_ERROR_TYPE = 'error.type' | ||
|
|
||
| ERROR_TYPE_MAP = { | ||
| 'FLAG_NOT_FOUND' => 'flag_not_found', | ||
| 'TYPE_MISMATCH' => 'type_mismatch', | ||
| 'PARSE_ERROR' => 'parse_error', | ||
| 'PROVIDER_NOT_READY' => 'provider_not_ready', | ||
| 'TARGETING_KEY_MISSING' => 'targeting_key_missing', | ||
| 'INVALID_CONTEXT' => 'invalid_context', | ||
| 'GENERAL' => 'general', | ||
| 'PROVIDER_FATAL' => 'general', | ||
| 'UNKNOWN_TYPE' => 'general', | ||
| }.freeze | ||
|
|
||
| REASON_MAP = { | ||
| 'TARGETING_MATCH' => 'targeting_match', | ||
| 'ERROR' => 'error', | ||
| 'DEFAULT' => 'default', | ||
| 'DISABLED' => 'disabled', | ||
| 'SPLIT' => 'split', | ||
| 'STATIC' => 'static', | ||
| 'UNKNOWN' => 'unknown', | ||
| }.freeze | ||
|
|
||
| EXCLUDE_ALLOCATION_KEY_REASONS = %w[ERROR DEFAULT DISABLED].freeze |
There was a problem hiding this comment.
I think these could go in an ext.rb file
There was a problem hiding this comment.
I considered this but decided against because
- The existing
ext.rbonly contains provider state constants (ERROR,GENERAL,PROVIDER_FATAL, etc.), and these are a separate concern. - These constants are internal to
FlagEvalMetricsand not shared elsewhere, so keeping them colocated with their usage seemed reasonable.
I am reading this feedback as a suggestion, but I am amenable to moving them if you feel strongly about it.
| @metrics = metrics | ||
| end | ||
|
|
||
| def finally(hook_context:, evaluation_details:, hints:) |
There was a problem hiding this comment.
hints seems to be unused here. I suppose that the goal is to make it compatible with ::OpenFeature::SDK::Hooks::Hook, in that case I'd suggest to replace it with:
| def finally(hook_context:, evaluation_details:, hints:) | |
| def finally(hook_context:, evaluation_details:, **opts) |
or something similarly named (rest, kwargs...) and also apply these changes in the .rbs and _spec.rb files.
There was a problem hiding this comment.
Yes, I was aiming for compatibility but your suggestion makes sense! Updated to use **_opts 35403da
|
|
||
| @telemetry: Core::Telemetry::Component | ||
| @logger: Core::Logger | ||
| @counter: untyped |
There was a problem hiding this comment.
Please type everything if possible. If it accepts any type, we have the type any defined in datadog.rbs. In that case, it seems to accept ::OpenTelemetry::Metrics::Instrument::Counter. You can define it in vendor/rbs/opentelemetry. We want to avoid untyped as much as possible
There was a problem hiding this comment.
Added vendor RBS types for OpenTelemetry metrics here: b4091cf. @counter is now typed as ::OpenTelemetry::Metrics::Instrument::Counter?
|
|
||
| def ensure_meter_provider_initialized!: () -> void | ||
|
|
||
| def meter_provider_available?: (untyped meter_provider) -> bool |
There was a problem hiding this comment.
Same here, it should probably be ::OpenTelemetry::Metrics::MeterProvider instead of untyped
|
|
||
| def build_flag_metadata: ( | ||
| untyped result | ||
| ) -> ::Hash[::String, untyped] |
There was a problem hiding this comment.
Same here, result should be (ResolutionDetails | Core::FeatureFlags::ResolutionDetails) and the return type ::Hash[::String, ::String] as it is result.flag_metadata type
There was a problem hiding this comment.
Updated the type signature. Used Core::FeatureFlags::ResolutionDetails since that's what EvaluationEngine#fetch_value returns internally 8a8a378
| # Stub the OpenFeature SDK Hook module before loading the hook | ||
| module OpenFeature | ||
| module SDK | ||
| module Hooks | ||
| module Hook | ||
| end | ||
| end | ||
| end | ||
| end |
There was a problem hiding this comment.
I believe it would be better to test it against the real OpenFeature::SDK::Hooks::Hook module. There is an openfeature appraisal available
There was a problem hiding this comment.
Moved these specs to run under the openfeature appraisal with the real SDK. Removed the stub module: bf7acd8
| # Create a fake OpenTelemetry module for stubbing | ||
| let(:otel_module) do | ||
| mod = Module.new | ||
| mod.define_singleton_method(:meter_provider) { nil } | ||
| mod | ||
| end |
There was a problem hiding this comment.
Again, I think it would be better to test it against the real OpenTelemetry module. If you need both opentelemetry and openfeature, you should be able to add opentelemetry to the openfeature appraisal.
There was a problem hiding this comment.
Removed the fake module for stubbing, and added opentelemetry-metrics-sdk to the openfeature appraisal bf7acd8
The `hints` parameter in the `finally` hook method is part of the OpenFeature SDK Hook interface but is not used by our implementation. Replace with `**_opts` to indicate it's intentionally unused while maintaining interface compatibility.
Add vendor RBS types for OpenTelemetry metrics SDK: - MeterProvider#meter method - Meter class with create_counter - Instrument::Counter with add method Update flag_eval_metrics.rbs to use proper types instead of untyped: - @counter: ::OpenTelemetry::SDK::Metrics::Instrument::Counter? - get_or_create_counter return type - meter_provider_available? parameter type
Type the result parameter as the union type returned by EvaluationEngine#fetch_value: (ResolutionDetails | Core::FeatureFlags::ResolutionDetails) Update return type to ::Hash[::String, ::String] to match the flag_metadata type from the result.
- Add opentelemetry-sdk and opentelemetry-metrics-sdk to openfeature appraisal for all Ruby versions - Update hook specs to require real OpenFeature SDK - Update metrics specs to require real OTel SDK - Make Hook module inclusion conditional to support SDK versions before 0.5.0 when Hooks::Hook was introduced - Fix type checker issues with local variable assignments
ca7429d to
bf7acd8
Compare
| metrics = @metrics | ||
| return unless metrics |
There was a problem hiding this comment.
I think this local metrics variable is not needed here?
| return false if meter_provider.nil? | ||
| return false unless defined?(::OpenTelemetry::SDK::Metrics::MeterProvider) | ||
|
|
||
| meter_provider.is_a?(::OpenTelemetry::SDK::Metrics::MeterProvider) |
There was a problem hiding this comment.
I don't understand this - we are getting the meter provider from OTel configuration, but for some reason checking that it is an instance of some particular class? Why do we do this?
| require 'opentelemetry-metrics-sdk' | ||
| require 'datadog/opentelemetry/metrics' | ||
| Datadog::OpenTelemetry::Metrics.initialize!(Datadog.send(:components)) | ||
| rescue LoadError, NameError => e |
|
|
||
| attrs = { | ||
| ATTR_FLAG_KEY => flag_key, | ||
| ATTR_VARIANT => variant || '', |
| attrs | ||
| end | ||
|
|
||
| def normalize_reason_with_upcase(reason) |
There was a problem hiding this comment.
this method name is confusing - from the name it is not clear that it returns a tuple
| REASON_MAP = { | ||
| 'TARGETING_MATCH' => 'targeting_match', | ||
| 'ERROR' => 'error', | ||
| 'DEFAULT' => 'default', | ||
| 'DISABLED' => 'disabled', | ||
| 'SPLIT' => 'split', | ||
| 'STATIC' => 'static', | ||
| 'UNKNOWN' => 'unknown', | ||
| }.freeze |
There was a problem hiding this comment.
this looks like a map of upcase strings to their downcased equivalents. Why do we even need that, and why can't we just downcase?
| def normalize_reason_with_upcase(reason) | ||
| return ['unknown', nil] if reason.nil? | ||
|
|
||
| reason_str = reason.to_s | ||
| return ['unknown', nil] if reason_str.empty? | ||
|
|
||
| reason_upcase = reason_str.upcase | ||
| normalized = REASON_MAP[reason_upcase] || reason_str.downcase | ||
| [normalized, reason_upcase] | ||
| end |
There was a problem hiding this comment.
I think this whole method could be removed if we try to understand what we really want to get from it. Do we need a tuple of strings, one downcased and one upcased, with fallback to 'unknownorUNKNOWN`?
| return 'general' if error_code.nil? | ||
|
|
||
| error_str = error_code.to_s | ||
| return 'general' if error_str.empty? | ||
|
|
||
| ERROR_TYPE_MAP[error_str] || 'general' |
There was a problem hiding this comment.
this whole method could be replaced with one #fetch:
ERROR_TYPE_MAP.fetch(error_code, 'general')| require_relative 'hooks/flag_eval_hook' | ||
| metrics = Metrics::FlagEvalMetrics.new(telemetry: @telemetry, logger: @logger) | ||
| Hooks::FlagEvalHook.new(metrics) | ||
| rescue LoadError, NameError |
|
|
||
| def hooks | ||
| hook = Datadog.send(:components).open_feature&.flag_eval_hook | ||
| hook ? [hook] : [] |
| def get_or_create_counter | ||
| @mutex.synchronize do | ||
| return @counter if @counter | ||
| return nil unless otel_metrics_enabled? |
There was a problem hiding this comment.
Does it make sense to move that condition out of the synchronize. When could it be that we have counter, but OTel metrics are not enabled? What will happen if someone disable OTel metrics, but counter is already there?
|
|
||
| ensure_meter_provider_initialized! | ||
| meter_provider = ::OpenTelemetry.meter_provider | ||
| return nil unless meter_provider && meter_provider_available?(meter_provider) |
There was a problem hiding this comment.
Similar condition is checked in ensure call above, what is the case here? Do we expect it as a possibility that it gets unavailable between calls?
| private | ||
|
|
||
| def create_flag_eval_hook | ||
| return nil unless defined?(::OpenFeature::SDK::Hooks::Hook) |
There was a problem hiding this comment.
This method leaks knowledge about hooks, when it also duped in hooks/flag_eval_hook file. This is a wrong responsibilities
What does this PR do?
Adds
feature_flag.evaluationscounter metric via OpenTelemetry to track flag evaluations.Motivation:
Enable observability of feature flag usage through standardized OTel metrics.
Change log entry
Yes. Add flag evaluation metrics (
feature_flag.evaluations) via OpenTelemetry for OpenFeature provider.Additional Notes:
feature_flag.key,feature_flag.result.variant,feature_flag.result.reasonfeature_flag.result.allocation_key,error.typeDD_METRICS_OTEL_ENABLED=trueto enable metrics (consistent with the other dd-trace-* SDKs)openfeature-sdk>= 0.5.1 for provider hooks supportHow to test the change?
bundle exec rspec spec/datadog/open_feature/metrics/ spec/datadog/open_feature/hooks/bundle exec steep check lib/datadog/open_feature/metrics lib/datadog/open_feature/hooksEnvironment variables: