Skip to content

Commit 0aff30f

Browse files
josecolellaclaude
andauthored
feat: add transaction context propagation (spec 3.3) (#230)
Signed-off-by: Jose Colella <jose.colella@gusto.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f6acb15 commit 0aff30f

9 files changed

Lines changed: 193 additions & 17 deletions

lib/open_feature/sdk.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
require_relative "sdk/version"
44
require_relative "sdk/api"
5+
require_relative "sdk/transaction_context_propagator"
6+
require_relative "sdk/thread_local_transaction_context_propagator"
57

68
module OpenFeature
79
# TODO: Add documentation

lib/open_feature/sdk/api.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ def logger=(new_logger)
7070
configuration.logger = new_logger
7171
end
7272

73+
def set_transaction_context_propagator(propagator)
74+
configuration.transaction_context_propagator = propagator
75+
end
76+
77+
def set_transaction_context(evaluation_context)
78+
propagator = configuration.transaction_context_propagator
79+
propagator&.set_transaction_context(evaluation_context)
80+
end
81+
7382
def shutdown
7483
configuration.shutdown
7584
end

lib/open_feature/sdk/client.rb

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,7 @@ def remove_handler(event_type, handler = nil, &block)
4949
def track(tracking_event_name, evaluation_context: nil, tracking_event_details: nil)
5050
return unless @provider.respond_to?(:track)
5151

52-
built_context = EvaluationContextBuilder.new.call(
53-
api_context: OpenFeature::SDK.evaluation_context,
54-
client_context: self.evaluation_context,
55-
invocation_context: evaluation_context
56-
)
52+
built_context = build_evaluation_context(evaluation_context)
5753

5854
@provider.track(tracking_event_name, evaluation_context: built_context, tracking_event_details: tracking_event_details)
5955
end
@@ -86,11 +82,7 @@ def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil, inv
8682
end
8783
end
8884

89-
built_context = EvaluationContextBuilder.new.call(
90-
api_context: OpenFeature::SDK.evaluation_context,
91-
client_context: self.evaluation_context,
92-
invocation_context: evaluation_context
93-
)
85+
built_context = build_evaluation_context(evaluation_context)
9486

9587
# Assemble ordered hooks: API → Client → Invocation → Provider (spec 4.4.2)
9688
provider_hooks = @provider.respond_to?(:hooks) ? Array(@provider.hooks) : []
@@ -148,6 +140,18 @@ def short_circuit_error_code(state)
148140
end
149141
end
150142

143+
def build_evaluation_context(invocation_context)
144+
propagator = OpenFeature::SDK.configuration.transaction_context_propagator
145+
transaction_context = propagator&.get_transaction_context
146+
147+
EvaluationContextBuilder.new.call(
148+
api_context: OpenFeature::SDK.evaluation_context,
149+
transaction_context: transaction_context,
150+
client_context: evaluation_context,
151+
invocation_context: invocation_context
152+
)
153+
end
154+
151155
def validate_default_value_type(type, default_value)
152156
expected_classes = TYPE_CLASS_MAP[type]
153157
unless expected_classes.any? { |klass| default_value.is_a?(klass) }

lib/open_feature/sdk/configuration.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ module SDK
1717
class Configuration
1818
extend Forwardable
1919

20-
attr_accessor :evaluation_context, :hooks
20+
attr_accessor :evaluation_context, :hooks, :transaction_context_propagator
2121
attr_reader :logger
2222

2323
def initialize

lib/open_feature/sdk/evaluation_context_builder.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ module OpenFeature
44
module SDK
55
# Used to combine evaluation contexts from different sources
66
class EvaluationContextBuilder
7-
def call(api_context:, client_context:, invocation_context:)
8-
available_contexts = [api_context, client_context, invocation_context].compact
7+
def call(api_context:, client_context:, invocation_context:, transaction_context: nil)
8+
available_contexts = [api_context, transaction_context, client_context, invocation_context].compact
99

1010
return nil if available_contexts.empty?
1111

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# frozen_string_literal: true
2+
3+
module OpenFeature
4+
module SDK
5+
class ThreadLocalTransactionContextPropagator
6+
include TransactionContextPropagator
7+
8+
THREAD_KEY = :openfeature_transaction_context
9+
10+
def set_transaction_context(evaluation_context)
11+
Thread.current[THREAD_KEY] = evaluation_context
12+
end
13+
14+
def get_transaction_context
15+
Thread.current[THREAD_KEY]
16+
end
17+
end
18+
end
19+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
module OpenFeature
4+
module SDK
5+
module TransactionContextPropagator
6+
def set_transaction_context(evaluation_context)
7+
raise NotImplementedError
8+
end
9+
10+
def get_transaction_context
11+
raise NotImplementedError
12+
end
13+
end
14+
end
15+
end
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe OpenFeature::SDK::ThreadLocalTransactionContextPropagator do
6+
subject(:propagator) { described_class.new }
7+
8+
after do
9+
Thread.current[described_class::THREAD_KEY] = nil
10+
end
11+
12+
describe "#set_transaction_context / #get_transaction_context" do
13+
it "round-trips an evaluation context" do
14+
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123")
15+
propagator.set_transaction_context(context)
16+
17+
expect(propagator.get_transaction_context).to eq(context)
18+
end
19+
20+
it "returns nil when no context has been set" do
21+
expect(propagator.get_transaction_context).to be_nil
22+
end
23+
24+
it "isolates context between threads" do
25+
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "main-thread")
26+
propagator.set_transaction_context(context)
27+
28+
other_thread_context = nil
29+
Thread.new { other_thread_context = propagator.get_transaction_context }.join
30+
31+
expect(other_thread_context).to be_nil
32+
expect(propagator.get_transaction_context).to eq(context)
33+
end
34+
end
35+
end
36+
37+
RSpec.describe OpenFeature::SDK::TransactionContextPropagator do
38+
describe "interface contract" do
39+
let(:klass) do
40+
Class.new do
41+
include OpenFeature::SDK::TransactionContextPropagator
42+
end
43+
end
44+
45+
it "raises NotImplementedError for #set_transaction_context" do
46+
expect { klass.new.set_transaction_context(nil) }.to raise_error(NotImplementedError)
47+
end
48+
49+
it "raises NotImplementedError for #get_transaction_context" do
50+
expect { klass.new.get_transaction_context }.to raise_error(NotImplementedError)
51+
end
52+
end
53+
end

spec/specification/evaluation_context_spec.rb

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,19 +68,93 @@
6868
end
6969
end
7070

71-
# TODO: We currently don't support transaction propogation and hooks
72-
# We'll want to fully implement this requirement once those are supported
7371
specify "Requirement 3.2.3 - Evaluation context MUST be merged in the order: API (global; lowest precedence) -> transaction -> client -> invocation -> before hooks (highest precedence), with duplicate values being overwritten." do
7472
api_context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "api")
73+
transaction_context = OpenFeature::SDK::EvaluationContext.new("targeting_key" => "transaction", "transaction-related" => "field")
7574
client_context = OpenFeature::SDK::EvaluationContext.new("targeting_key" => "client", "client-related" => "field")
7675
invocation_context = OpenFeature::SDK::EvaluationContext.new("targeting_key" => "invocation", "invocation-related" => "field")
7776

78-
OpenFeature::SDK.configure { |c| c.evaluation_context = api_context }
77+
propagator = OpenFeature::SDK::ThreadLocalTransactionContextPropagator.new
78+
OpenFeature::SDK.configure do |c|
79+
c.evaluation_context = api_context
80+
c.transaction_context_propagator = propagator
81+
end
82+
propagator.set_transaction_context(transaction_context)
83+
7984
client = OpenFeature::SDK.build_client(evaluation_context: client_context)
8085

81-
expect_any_instance_of(OpenFeature::SDK::EvaluationContextBuilder).to receive(:call).with(api_context:, client_context:, invocation_context:).and_call_original
86+
expect_any_instance_of(OpenFeature::SDK::EvaluationContextBuilder).to receive(:call).with(
87+
api_context: api_context,
88+
transaction_context: transaction_context,
89+
client_context: client_context,
90+
invocation_context: invocation_context
91+
).and_call_original
8292

8393
client.fetch_boolean_value(flag_key: "testing", default_value: true, evaluation_context: invocation_context)
94+
95+
propagator.set_transaction_context(nil)
96+
end
97+
end
98+
99+
context "3.3 Transaction Context Propagation" do
100+
after do
101+
OpenFeature::SDK.configure { |c| c.transaction_context_propagator = nil }
102+
end
103+
104+
context "Requirement 3.3.1.1" do
105+
specify "The API SHOULD have a method for setting a transaction context propagator." do
106+
propagator = OpenFeature::SDK::ThreadLocalTransactionContextPropagator.new
107+
OpenFeature::SDK.set_transaction_context_propagator(propagator)
108+
109+
expect(OpenFeature::SDK.configuration.transaction_context_propagator).to eq(propagator)
110+
end
111+
end
112+
113+
context "Condition 3.3.1.2 - A transaction context propagator is configured." do
114+
let(:propagator) { OpenFeature::SDK::ThreadLocalTransactionContextPropagator.new }
115+
116+
before do
117+
OpenFeature::SDK.set_transaction_context_propagator(propagator)
118+
end
119+
120+
context "Conditional Requirement 3.3.1.2.1" do
121+
specify "The API MUST have a method for setting the evaluation context of the transaction context propagator for the current transaction." do
122+
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "txn-user")
123+
OpenFeature::SDK.set_transaction_context(context)
124+
125+
expect(propagator.get_transaction_context).to eq(context)
126+
127+
propagator.set_transaction_context(nil)
128+
end
129+
end
130+
end
131+
132+
context "Requirement 3.3.1.2.2" do
133+
specify "A transaction context propagator MUST have a method for setting the evaluation context of the current transaction." do
134+
propagator = OpenFeature::SDK::ThreadLocalTransactionContextPropagator.new
135+
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "txn-user")
136+
137+
propagator.set_transaction_context(context)
138+
139+
expect(propagator.get_transaction_context).to eq(context)
140+
141+
propagator.set_transaction_context(nil)
142+
end
143+
end
144+
145+
context "Requirement 3.3.1.2.3" do
146+
specify "A transaction context propagator MUST have a method for getting the evaluation context of the current transaction." do
147+
propagator = OpenFeature::SDK::ThreadLocalTransactionContextPropagator.new
148+
149+
expect(propagator.get_transaction_context).to be_nil
150+
151+
context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "txn-user")
152+
propagator.set_transaction_context(context)
153+
154+
expect(propagator.get_transaction_context).to eq(context)
155+
156+
propagator.set_transaction_context(nil)
157+
end
84158
end
85159
end
86160
end

0 commit comments

Comments
 (0)