Skip to content

Commit 2f681c9

Browse files
josecolellaclaude
andauthored
feat: add logging hook (spec Appendix A) (#229)
Signed-off-by: Jose Colella <jose.colella@gusto.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0aff30f commit 2f681c9

3 files changed

Lines changed: 333 additions & 0 deletions

File tree

lib/open_feature/sdk/hooks.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
require_relative "hooks/hook"
55
require_relative "hooks/hook_context"
66
require_relative "hooks/hook_executor"
7+
require_relative "hooks/logging_hook"
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# frozen_string_literal: true
2+
3+
module OpenFeature
4+
module SDK
5+
module Hooks
6+
class LoggingHook
7+
include Hook
8+
9+
def initialize(logger: nil, include_evaluation_context: false)
10+
@logger = logger
11+
@include_evaluation_context = include_evaluation_context
12+
end
13+
14+
def before(hook_context:, hints:)
15+
logger&.debug { build_before_message(hook_context) }
16+
nil
17+
end
18+
19+
def after(hook_context:, evaluation_details:, hints:)
20+
logger&.debug { build_after_message(hook_context, evaluation_details) }
21+
nil
22+
end
23+
24+
def error(hook_context:, exception:, hints:)
25+
logger&.error { build_error_message(hook_context, exception) }
26+
nil
27+
end
28+
29+
private
30+
31+
def logger
32+
@logger || OpenFeature::SDK.configuration.logger
33+
end
34+
35+
def build_before_message(hook_context)
36+
parts = base_parts("before", hook_context)
37+
parts << "evaluation_context=#{format_context(hook_context.evaluation_context)}" if @include_evaluation_context
38+
parts.join(" ")
39+
end
40+
41+
def build_after_message(hook_context, evaluation_details)
42+
parts = base_parts("after", hook_context)
43+
parts << "reason=#{sanitize(evaluation_details.reason)}" if evaluation_details.reason
44+
parts << "variant=#{sanitize(evaluation_details.variant)}" if evaluation_details.variant
45+
parts << "value=#{sanitize(evaluation_details.value)}"
46+
parts << "evaluation_context=#{format_context(hook_context.evaluation_context)}" if @include_evaluation_context
47+
parts.join(" ")
48+
end
49+
50+
def build_error_message(hook_context, exception)
51+
parts = base_parts("error", hook_context)
52+
error_code = exception.respond_to?(:error_code) ? exception.error_code : Provider::ErrorCode::GENERAL
53+
parts << "error_code=#{sanitize(error_code)}"
54+
parts << "error_message=#{sanitize(exception.message)}"
55+
parts << "evaluation_context=#{format_context(hook_context.evaluation_context)}" if @include_evaluation_context
56+
parts.join(" ")
57+
end
58+
59+
def base_parts(stage, hook_context)
60+
domain = hook_context.client_metadata&.domain
61+
provider_name = hook_context.provider_metadata&.name
62+
[
63+
"stage=#{stage}",
64+
"domain=#{sanitize(domain)}",
65+
"provider_name=#{sanitize(provider_name)}",
66+
"flag_key=#{sanitize(hook_context.flag_key)}",
67+
"default_value=#{sanitize(hook_context.default_value)}"
68+
]
69+
end
70+
71+
def format_context(evaluation_context)
72+
return "" unless evaluation_context
73+
fields = evaluation_context.fields.dup
74+
fields["targeting_key"] = evaluation_context.targeting_key if evaluation_context.targeting_key
75+
fields.map { |k, v| "#{sanitize(k)}=#{sanitize(v)}" }.join(", ")
76+
end
77+
78+
def sanitize(value)
79+
value.to_s.gsub(/[\n\r]/, " ")
80+
end
81+
end
82+
end
83+
end
84+
end
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe OpenFeature::SDK::Hooks::LoggingHook do
6+
let(:client_metadata) { OpenFeature::SDK::ClientMetadata.new(domain: "test-domain") }
7+
let(:provider_metadata) { OpenFeature::SDK::Provider::ProviderMetadata.new(name: "test-provider") }
8+
let(:evaluation_context) { OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123", env: "prod") }
9+
10+
let(:hook_context) do
11+
OpenFeature::SDK::Hooks::HookContext.new(
12+
flag_key: "my-flag",
13+
flag_value_type: :boolean,
14+
default_value: false,
15+
evaluation_context: evaluation_context,
16+
client_metadata: client_metadata,
17+
provider_metadata: provider_metadata
18+
)
19+
end
20+
21+
let(:evaluation_details) do
22+
resolution = OpenFeature::SDK::Provider::ResolutionDetails.new(
23+
value: true,
24+
reason: "TARGETING_MATCH",
25+
variant: "enabled"
26+
)
27+
OpenFeature::SDK::EvaluationDetails.new(
28+
flag_key: "my-flag",
29+
resolution_details: resolution
30+
)
31+
end
32+
33+
let(:logger) { double("logger") }
34+
let(:hints) { OpenFeature::SDK::Hooks::Hints.new }
35+
36+
describe "#before" do
37+
it "logs at debug level with correct fields" do
38+
hook = described_class.new(logger: logger)
39+
expect(logger).to receive(:debug) do |&block|
40+
message = block.call
41+
expect(message).to include("stage=before")
42+
expect(message).to include("domain=test-domain")
43+
expect(message).to include("provider_name=test-provider")
44+
expect(message).to include("flag_key=my-flag")
45+
expect(message).to include("default_value=false")
46+
end
47+
48+
hook.before(hook_context: hook_context, hints: hints)
49+
end
50+
51+
it "does not include evaluation_context by default" do
52+
hook = described_class.new(logger: logger)
53+
expect(logger).to receive(:debug) do |&block|
54+
message = block.call
55+
expect(message).not_to include("evaluation_context=")
56+
end
57+
58+
hook.before(hook_context: hook_context, hints: hints)
59+
end
60+
61+
it "includes evaluation_context when enabled" do
62+
hook = described_class.new(logger: logger, include_evaluation_context: true)
63+
expect(logger).to receive(:debug) do |&block|
64+
message = block.call
65+
expect(message).to include("evaluation_context=")
66+
expect(message).to include("targeting_key=user-123")
67+
expect(message).to include("env=prod")
68+
end
69+
70+
hook.before(hook_context: hook_context, hints: hints)
71+
end
72+
73+
it "returns nil" do
74+
hook = described_class.new(logger: logger)
75+
allow(logger).to receive(:debug)
76+
expect(hook.before(hook_context: hook_context, hints: hints)).to be_nil
77+
end
78+
end
79+
80+
describe "#after" do
81+
it "logs at debug level with evaluation details" do
82+
hook = described_class.new(logger: logger)
83+
expect(logger).to receive(:debug) do |&block|
84+
message = block.call
85+
expect(message).to include("stage=after")
86+
expect(message).to include("domain=test-domain")
87+
expect(message).to include("provider_name=test-provider")
88+
expect(message).to include("flag_key=my-flag")
89+
expect(message).to include("default_value=false")
90+
expect(message).to include("reason=TARGETING_MATCH")
91+
expect(message).to include("variant=enabled")
92+
expect(message).to include("value=true")
93+
end
94+
95+
hook.after(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
96+
end
97+
98+
it "omits reason and variant when nil" do
99+
resolution = OpenFeature::SDK::Provider::ResolutionDetails.new(value: "hello")
100+
details = OpenFeature::SDK::EvaluationDetails.new(flag_key: "my-flag", resolution_details: resolution)
101+
hook = described_class.new(logger: logger)
102+
103+
expect(logger).to receive(:debug) do |&block|
104+
message = block.call
105+
expect(message).not_to include("reason=")
106+
expect(message).not_to include("variant=")
107+
expect(message).to include("value=hello")
108+
end
109+
110+
hook.after(hook_context: hook_context, evaluation_details: details, hints: hints)
111+
end
112+
113+
it "includes evaluation_context when enabled" do
114+
hook = described_class.new(logger: logger, include_evaluation_context: true)
115+
expect(logger).to receive(:debug) do |&block|
116+
message = block.call
117+
expect(message).to include("evaluation_context=")
118+
expect(message).to include("targeting_key=user-123")
119+
end
120+
121+
hook.after(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
122+
end
123+
124+
it "returns nil" do
125+
hook = described_class.new(logger: logger)
126+
allow(logger).to receive(:debug)
127+
expect(hook.after(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)).to be_nil
128+
end
129+
end
130+
131+
describe "#error" do
132+
let(:exception) { StandardError.new("something went wrong") }
133+
134+
it "logs at error level with error fields" do
135+
hook = described_class.new(logger: logger)
136+
expect(logger).to receive(:error) do |&block|
137+
message = block.call
138+
expect(message).to include("stage=error")
139+
expect(message).to include("domain=test-domain")
140+
expect(message).to include("provider_name=test-provider")
141+
expect(message).to include("flag_key=my-flag")
142+
expect(message).to include("default_value=false")
143+
expect(message).to include("error_code=GENERAL")
144+
expect(message).to include("error_message=something went wrong")
145+
end
146+
147+
hook.error(hook_context: hook_context, exception: exception, hints: hints)
148+
end
149+
150+
it "uses error_code from exception when available" do
151+
error_with_code = OpenFeature::SDK::ProviderInitializationError.new("init failed")
152+
hook = described_class.new(logger: logger)
153+
154+
expect(logger).to receive(:error) do |&block|
155+
message = block.call
156+
expect(message).to include("error_code=PROVIDER_FATAL")
157+
end
158+
159+
hook.error(hook_context: hook_context, exception: error_with_code, hints: hints)
160+
end
161+
162+
it "includes evaluation_context when enabled" do
163+
hook = described_class.new(logger: logger, include_evaluation_context: true)
164+
expect(logger).to receive(:error) do |&block|
165+
message = block.call
166+
expect(message).to include("evaluation_context=")
167+
expect(message).to include("targeting_key=user-123")
168+
end
169+
170+
hook.error(hook_context: hook_context, exception: exception, hints: hints)
171+
end
172+
173+
it "returns nil" do
174+
hook = described_class.new(logger: logger)
175+
allow(logger).to receive(:error)
176+
expect(hook.error(hook_context: hook_context, exception: exception, hints: hints)).to be_nil
177+
end
178+
end
179+
180+
describe "#finally" do
181+
it "does not log (uses default no-op from Hook module)" do
182+
hook = described_class.new(logger: logger)
183+
expect(logger).not_to receive(:debug)
184+
expect(logger).not_to receive(:info)
185+
expect(logger).not_to receive(:error)
186+
187+
hook.finally(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
188+
end
189+
end
190+
191+
describe "logger fallback" do
192+
it "falls back to OpenFeature::SDK.configuration.logger" do
193+
config_logger = double("config_logger")
194+
allow(OpenFeature::SDK).to receive(:configuration).and_return(
195+
instance_double(OpenFeature::SDK::Configuration, logger: config_logger)
196+
)
197+
198+
hook = described_class.new
199+
expect(config_logger).to receive(:debug)
200+
201+
hook.before(hook_context: hook_context, hints: hints)
202+
end
203+
204+
it "handles nil logger gracefully" do
205+
allow(OpenFeature::SDK).to receive(:configuration).and_return(
206+
instance_double(OpenFeature::SDK::Configuration, logger: nil)
207+
)
208+
209+
hook = described_class.new
210+
expect { hook.before(hook_context: hook_context, hints: hints) }.not_to raise_error
211+
end
212+
end
213+
214+
describe "log injection sanitization" do
215+
it "sanitizes newlines in logged values" do
216+
injected_context = OpenFeature::SDK::Hooks::HookContext.new(
217+
flag_key: "safe-key\ninjected=malicious",
218+
flag_value_type: :boolean,
219+
default_value: false,
220+
evaluation_context: evaluation_context,
221+
client_metadata: client_metadata,
222+
provider_metadata: provider_metadata
223+
)
224+
225+
hook = described_class.new(logger: logger)
226+
expect(logger).to receive(:debug) do |&block|
227+
message = block.call
228+
expect(message).not_to include("\n")
229+
expect(message).to include("safe-key injected=malicious")
230+
end
231+
232+
hook.before(hook_context: injected_context, hints: hints)
233+
end
234+
235+
it "sanitizes newlines in error messages" do
236+
exception = StandardError.new("error\nfake_entry=injected")
237+
hook = described_class.new(logger: logger)
238+
239+
expect(logger).to receive(:error) do |&block|
240+
message = block.call
241+
expect(message).not_to include("\n")
242+
expect(message).to include("error fake_entry=injected")
243+
end
244+
245+
hook.error(hook_context: hook_context, exception: exception, hints: hints)
246+
end
247+
end
248+
end

0 commit comments

Comments
 (0)