-
Notifications
You must be signed in to change notification settings - Fork 397
[FFL-1361] Evaluation in binding in ruby #5022
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: ffl-1319-add-agent-communication-for-openfeature
Are you sure you want to change the base?
Changes from all commits
43e97b1
aa2fd5a
32ca2ff
9e5c4f5
cef56ef
3272b08
64d0591
9e23207
0d738fd
62c55e3
c1ce9a9
2d7c83f
c98fc66
b8cb103
5250a58
0e8164f
c6899b9
1e1c2f9
7991c83
9ed6cd3
c5fbfc2
0f61d96
7be46ca
aae511e
2179ba3
500cefb
9bf8e60
fa9c847
882f01d
547bf57
18d1efc
678ebde
c7a1c00
5ec359f
6d60cf3
39ee406
9e7ec9b
641a6fb
0a8139c
e86e4e2
703eac6
ea5beb4
8352517
ec5e23d
c62d6ef
f8d364f
f0d3309
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Datadog | ||
| module OpenFeature | ||
| module Binding | ||
| # Assignment reasons returned in ResolutionDetails | ||
| # Aligned with libdatadog FFI Reason enum | ||
| module AssignmentReason | ||
| TARGETING_MATCH = 'TARGETING_MATCH' | ||
| SPLIT = 'SPLIT' | ||
| STATIC = 'STATIC' | ||
| DEFAULT = 'DEFAULT' # For DefaultAllocationNull cases (matches libdatadog FFI Reason::Default) | ||
| DISABLED = 'DISABLED' # For FlagDisabled cases | ||
| ERROR = 'ERROR' | ||
| end | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Datadog | ||
| module OpenFeature | ||
| module Binding | ||
| # Condition operators for rule evaluation | ||
| module ConditionOperator | ||
| MATCHES = 'MATCHES' | ||
| NOT_MATCHES = 'NOT_MATCHES' | ||
| GTE = 'GTE' | ||
| GT = 'GT' | ||
| LTE = 'LTE' | ||
| LT = 'LT' | ||
| ONE_OF = 'ONE_OF' | ||
| NOT_ONE_OF = 'NOT_ONE_OF' | ||
| IS_NULL = 'IS_NULL' | ||
| end | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,252 @@ | ||||||||||
| # frozen_string_literal: true | ||||||||||
|
|
||||||||||
| require_relative 'variation_type' | ||||||||||
| require_relative 'condition_operator' | ||||||||||
| require_relative 'assignment_reason' | ||||||||||
|
|
||||||||||
| module Datadog | ||||||||||
| module OpenFeature | ||||||||||
| module Binding | ||||||||||
| # Represents a feature flag configuration | ||||||||||
| class Flag | ||||||||||
| attr_reader :key, :enabled, :variation_type, :variations, :allocations | ||||||||||
|
|
||||||||||
| def initialize(key:, enabled:, variation_type:, variations:, allocations:) | ||||||||||
| @key = key | ||||||||||
| @enabled = enabled | ||||||||||
| @variation_type = variation_type | ||||||||||
| @variations = Hash(variations) | ||||||||||
| @allocations = Array(allocations) | ||||||||||
| end | ||||||||||
|
|
||||||||||
| def self.from_hash(flag_data, key) | ||||||||||
| new( | ||||||||||
| key: key, | ||||||||||
| enabled: flag_data.fetch('enabled', false), | ||||||||||
| variation_type: flag_data.fetch('variationType'), | ||||||||||
| variations: parse_variations(flag_data.fetch('variations', {})), | ||||||||||
| allocations: parse_allocations(flag_data.fetch('allocations', [])) | ||||||||||
| ) | ||||||||||
| end | ||||||||||
|
|
||||||||||
| private | ||||||||||
|
|
||||||||||
| def self.parse_variations(variations_data) | ||||||||||
| variations_data.transform_values do |variation_data| | ||||||||||
| Variation.from_hash(variation_data) | ||||||||||
| end | ||||||||||
| end | ||||||||||
|
|
||||||||||
| def self.parse_allocations(allocations_data) | ||||||||||
| allocations_data.map { |allocation_data| Allocation.from_hash(allocation_data) } | ||||||||||
| end | ||||||||||
| end | ||||||||||
|
|
||||||||||
| # Represents a flag variation with a key for logging and a value for the application | ||||||||||
| class Variation | ||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It contains a dd-trace-rb/spec/fixtures/ufc/flags-v1.json Lines 747 to 750 in 49dafd3
I've updated the comment since that seems to be causing confusion: 7d966c4 |
||||||||||
| attr_reader :key, :value | ||||||||||
|
|
||||||||||
| def initialize(key:, value:) | ||||||||||
| @key = key | ||||||||||
| @value = value | ||||||||||
| end | ||||||||||
|
|
||||||||||
| def self.from_hash(variation_data) | ||||||||||
| new( | ||||||||||
| key: variation_data.fetch('key'), | ||||||||||
| value: variation_data.fetch('value') | ||||||||||
| ) | ||||||||||
| end | ||||||||||
| end | ||||||||||
|
|
||||||||||
| # Represents an allocation rule with traffic splits | ||||||||||
| class Allocation | ||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does it make sense to name this class
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. An allocation contains a few things: rules (matches deterministically), splits (matches randomly but consistently, using a hash function), if the flag assignment should be logged for experiment analysis. This is a pattern we are bringing to DD. Also it'll be easier to see which fields in the flag configuration are being represented if the class and key names match. dd-trace-rb/spec/fixtures/ufc/flags-v1.json Line 768 in 49dafd3
Much of this is temporary. It will be replaced by a C binding to an evaluator in libdatadog and then the Ruby evaluation code can be deleted. In the meantime, I'm matching up the Ruby evaluator as closely as I can in structure and function to the one in Rust. We are hedging dependencies so we can get the end to end working asap |
||||||||||
| attr_reader :key, :rules, :start_at, :end_at, :splits, :do_log | ||||||||||
|
|
||||||||||
| def initialize(key:, rules: nil, start_at: nil, end_at: nil, splits:, do_log: true) | ||||||||||
| @key = key | ||||||||||
| @rules = rules | ||||||||||
| @start_at = start_at | ||||||||||
| @end_at = end_at | ||||||||||
| @splits = Array(splits) | ||||||||||
| @do_log = do_log | ||||||||||
| end | ||||||||||
|
|
||||||||||
| def self.from_hash(allocation_data) | ||||||||||
| new( | ||||||||||
| key: allocation_data.fetch('key'), | ||||||||||
| rules: parse_rules(allocation_data['rules']), | ||||||||||
| start_at: parse_timestamp(allocation_data['startAt']), | ||||||||||
| end_at: parse_timestamp(allocation_data['endAt']), | ||||||||||
| splits: parse_splits(allocation_data.fetch('splits', [])), | ||||||||||
| do_log: allocation_data.fetch('doLog', true) | ||||||||||
| ) | ||||||||||
| end | ||||||||||
|
|
||||||||||
| private | ||||||||||
|
|
||||||||||
| def self.parse_rules(rules_data) | ||||||||||
| return nil if rules_data.nil? || rules_data.empty? | ||||||||||
|
|
||||||||||
| rules_data.map { |rule_data| Rule.from_hash(rule_data) } | ||||||||||
| end | ||||||||||
|
|
||||||||||
| def self.parse_splits(splits_data) | ||||||||||
| splits_data.map { |split_data| Split.from_hash(split_data) } | ||||||||||
| end | ||||||||||
|
|
||||||||||
| def self.parse_timestamp(timestamp_data) | ||||||||||
| # Handle both Unix timestamps and ISO8601 strings | ||||||||||
| case timestamp_data | ||||||||||
| when Numeric | ||||||||||
| Time.at(timestamp_data) | ||||||||||
| when String | ||||||||||
| Time.parse(timestamp_data) | ||||||||||
| else | ||||||||||
| nil | ||||||||||
| end | ||||||||||
| rescue StandardError | ||||||||||
| nil | ||||||||||
| end | ||||||||||
| end | ||||||||||
|
|
||||||||||
| # Represents a traffic split within an allocation | ||||||||||
| class Split | ||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it'll be easier to see how this corresponds to the fields with the same names in the JSON if the key and class names match, e.g. dd-trace-rb/spec/fixtures/ufc/flags-v1.json Line 728 in 49dafd3
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tbh I think if you are adding an abstraction over this data to avoid working with a big Hash directly, it makes no sense to duplicate key names 1:1 to class names. The class names should make sense on their own, since it helps with overall code readability. But I guess this is your decision in the end, especially if this code is temporary as you mentioned. |
||||||||||
| attr_reader :shards, :variation_key, :extra_logging | ||||||||||
|
|
||||||||||
| def initialize(shards:, variation_key:, extra_logging: nil) | ||||||||||
| @shards = Array(shards) | ||||||||||
| @variation_key = variation_key | ||||||||||
| @extra_logging = Hash(extra_logging) | ||||||||||
| end | ||||||||||
|
|
||||||||||
| def self.from_hash(split_data) | ||||||||||
| new( | ||||||||||
| shards: parse_shards(split_data.fetch('shards', [])), | ||||||||||
| variation_key: split_data.fetch('variationKey'), | ||||||||||
| extra_logging: split_data.fetch('extraLogging', {}) | ||||||||||
| ) | ||||||||||
| end | ||||||||||
|
|
||||||||||
| private | ||||||||||
|
|
||||||||||
| def self.parse_shards(shards_data) | ||||||||||
| shards_data.map { |shard_data| Shard.from_hash(shard_data) } | ||||||||||
| end | ||||||||||
| end | ||||||||||
|
|
||||||||||
| # Represents a shard configuration for traffic splitting | ||||||||||
| class Shard | ||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not sure about this name - it's called
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another case of exactly matching the key name in the JSON dd-trace-rb/spec/fixtures/ufc/flags-v1.json Line 831 in 49dafd3
|
||||||||||
| attr_reader :salt, :total_shards, :ranges | ||||||||||
|
|
||||||||||
| def initialize(salt:, total_shards:, ranges:) | ||||||||||
| @salt = salt | ||||||||||
| @total_shards = total_shards | ||||||||||
| @ranges = Array(ranges) | ||||||||||
| end | ||||||||||
|
|
||||||||||
| def self.from_hash(shard_data) | ||||||||||
| new( | ||||||||||
| salt: shard_data.fetch('salt'), | ||||||||||
| total_shards: shard_data.fetch('totalShards'), | ||||||||||
| ranges: parse_ranges(shard_data.fetch('ranges', [])) | ||||||||||
| ) | ||||||||||
| end | ||||||||||
|
|
||||||||||
| private | ||||||||||
|
|
||||||||||
| def self.parse_ranges(ranges_data) | ||||||||||
| ranges_data.map { |range_data| ShardRange.from_hash(range_data) } | ||||||||||
| end | ||||||||||
| end | ||||||||||
|
|
||||||||||
| # Represents a shard range for traffic allocation | ||||||||||
| class ShardRange | ||||||||||
| attr_reader :start, :end_value | ||||||||||
|
|
||||||||||
| def initialize(start:, end_value:) | ||||||||||
| @start = start | ||||||||||
| @end_value = end_value | ||||||||||
| end | ||||||||||
|
|
||||||||||
| def self.from_hash(range_data) | ||||||||||
| new( | ||||||||||
| start: range_data.fetch('start'), | ||||||||||
| end_value: range_data.fetch('end') | ||||||||||
| ) | ||||||||||
| end | ||||||||||
|
|
||||||||||
| # Alias because "end" is a reserved keyword in Ruby | ||||||||||
| alias_method :end, :end_value | ||||||||||
| end | ||||||||||
|
|
||||||||||
| # Represents a targeting rule | ||||||||||
| class Rule | ||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also matching the field name in the universal flag configuration dd-trace-rb/spec/fixtures/ufc/flags-v1.json Line 813 in 49dafd3
|
||||||||||
| attr_reader :conditions | ||||||||||
|
|
||||||||||
| def initialize(conditions:) | ||||||||||
| @conditions = Array(conditions) | ||||||||||
| end | ||||||||||
|
|
||||||||||
| def self.from_hash(rule_data) | ||||||||||
| new( | ||||||||||
| conditions: parse_conditions(rule_data.fetch('conditions', [])) | ||||||||||
| ) | ||||||||||
| end | ||||||||||
|
|
||||||||||
| private | ||||||||||
|
|
||||||||||
| def self.parse_conditions(conditions_data) | ||||||||||
| conditions_data.map { |condition_data| Condition.from_hash(condition_data) } | ||||||||||
| end | ||||||||||
| end | ||||||||||
|
|
||||||||||
| # Represents a single condition within a rule | ||||||||||
| class Condition | ||||||||||
| attr_reader :attribute, :operator, :value | ||||||||||
|
|
||||||||||
| def initialize(attribute:, operator:, value:) | ||||||||||
| @attribute = attribute | ||||||||||
| @operator = operator | ||||||||||
| @value = value | ||||||||||
| end | ||||||||||
|
|
||||||||||
| def self.from_hash(condition_data) | ||||||||||
| new( | ||||||||||
| attribute: condition_data.fetch('attribute'), | ||||||||||
| operator: condition_data.fetch('operator'), | ||||||||||
| value: condition_data.fetch('value') | ||||||||||
| ) | ||||||||||
| end | ||||||||||
|
|
||||||||||
| end | ||||||||||
|
|
||||||||||
| # Main configuration container | ||||||||||
| class Configuration | ||||||||||
| attr_reader :flags, :schema_version | ||||||||||
|
|
||||||||||
| def initialize(flags:, schema_version: nil) | ||||||||||
| @flags = Hash(flags) | ||||||||||
| @schema_version = schema_version | ||||||||||
| end | ||||||||||
|
|
||||||||||
| def self.from_hash(config_data) | ||||||||||
| flags_data = config_data.fetch('flags', {}) | ||||||||||
|
|
||||||||||
| parsed_flags = flags_data.transform_values do |flag_data| | ||||||||||
| Flag.from_hash(flag_data, flag_data['key'] || '') | ||||||||||
| end | ||||||||||
|
|
||||||||||
| new( | ||||||||||
| flags: parsed_flags, | ||||||||||
| schema_version: config_data['schemaVersion'] | ||||||||||
| ) | ||||||||||
| end | ||||||||||
|
|
||||||||||
| def get_flag(flag_key) | ||||||||||
| @flags.values.find { |flag| flag.key == flag_key } | ||||||||||
| end | ||||||||||
| end | ||||||||||
| end | ||||||||||
| end | ||||||||||
| end | ||||||||||
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The aspiration is that in #5007 when it is ready, we can just switch this to
And then we're using the libdatadog FFE powered evaluator