Skip to content

Commit 0a510d3

Browse files
committed
feat: added evaluation hooks for variable calls
1 parent cb5d4a3 commit 0a510d3

File tree

7 files changed

+1144
-26
lines changed

7 files changed

+1144
-26
lines changed

lib/devcycle-ruby-server-sdk/api/client.rb

Lines changed: 80 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def initialize(sdkKey, dvc_options = Options.new, wait_for_init = false)
1414
@sdkKey = sdkKey
1515
@dvc_options = dvc_options
1616
@logger = dvc_options.logger
17+
@eval_hooks_runner = EvalHooksRunner.new
1718

1819
if @dvc_options.enable_cloud_bucketing
1920
@api_client = ApiClient.default
@@ -172,35 +173,62 @@ def variable(user, key, default, opts = {})
172173

173174
validate_model(user)
174175

175-
if @dvc_options.enable_cloud_bucketing
176-
data, _status_code, _headers = variable_with_http_info(key, user, default, opts)
177-
return data
176+
# Create hook context
177+
hook_context = HookContext.new(key: key, user: user, default_value: default)
178+
179+
before_hook_error = nil
180+
# Run before hooks
181+
begin
182+
hook_context = @eval_hooks_runner.run_before_hooks(hook_context)
183+
rescue => e
184+
before_hook_error = e
185+
@logger.warn("Error in before hooks: #{e.message}")
178186
end
179187

180-
value = default
181-
type = determine_variable_type(default)
182-
defaulted = true
183-
if local_bucketing_initialized? && @local_bucketing.has_config
184-
type_code = variable_type_code_from_type(type)
185-
variable_pb = variable_for_user_pb(user, key, type_code)
186-
unless variable_pb.nil?
187-
value = get_variable_value(variable_pb)
188-
defaulted = false
188+
variable_result = nil
189+
190+
begin
191+
if @dvc_options.enable_cloud_bucketing
192+
data, _status_code, _headers = variable_with_http_info(key, user, default, opts)
193+
variable_result = data
194+
else
195+
value = default
196+
type = determine_variable_type(default)
197+
defaulted = true
198+
if local_bucketing_initialized? && @local_bucketing.has_config
199+
type_code = variable_type_code_from_type(type)
200+
variable_pb = variable_for_user_pb(user, key, type_code)
201+
unless variable_pb.nil?
202+
value = get_variable_value(variable_pb)
203+
defaulted = false
204+
end
205+
else
206+
@logger.warn("Local bucketing not initialized, returning default value for variable #{key}")
207+
variable_event = Event.new({ type: DevCycle::EventTypes[:agg_variable_defaulted], target: key })
208+
bucketed_config = BucketedUserConfig.new({}, {}, {}, {}, {}, {}, [])
209+
@event_queue.queue_aggregate_event(variable_event, bucketed_config)
210+
end
211+
212+
variable_result = Variable.new({
213+
key: key,
214+
value: value,
215+
type: type,
216+
defaultValue: default,
217+
isDefaulted: defaulted
218+
})
189219
end
190-
else
191-
@logger.warn("Local bucketing not initialized, returning default value for variable #{key}")
192-
variable_event = Event.new({ type: DevCycle::EventTypes[:agg_variable_defaulted], target: key })
193-
bucketed_config = BucketedUserConfig.new({}, {}, {}, {}, {}, {}, [])
194-
@event_queue.queue_aggregate_event(variable_event, bucketed_config)
195-
end
196-
197-
Variable.new({
198-
key: key,
199-
value: value,
200-
type: type,
201-
defaultValue: default,
202-
isDefaulted: defaulted
203-
})
220+
221+
# Run after hooks only if no before hook error occurred
222+
@eval_hooks_runner.run_after_hooks(hook_context) if before_hook_error.nil?
223+
rescue => e
224+
# Run error hooks
225+
@eval_hooks_runner.run_error_hooks(hook_context, e)
226+
ensure
227+
# Run finally hooks in all cases
228+
@eval_hooks_runner.run_finally_hooks(hook_context)
229+
end
230+
231+
variable_result
204232
end
205233

206234
def variable_for_user(user, key, variable_type_code)
@@ -526,6 +554,32 @@ def variable_type_pb_code_from_type_code(type_code)
526554
raise ArgumentError.new("Invalid type code for variable: #{type_code}")
527555
end
528556
end
557+
558+
def get_variable_value(variable_pb)
559+
case variable_pb.type
560+
when :Boolean
561+
variable_pb.boolValue
562+
when :Number
563+
variable_pb.doubleValue
564+
when :String
565+
variable_pb.stringValue
566+
when :JSON
567+
JSON.parse variable_pb.stringValue
568+
end
569+
end
570+
571+
# Adds an eval hook to the client
572+
# @param [EvalHook] eval_hook The eval hook to add
573+
# @return [void]
574+
def add_eval_hook(eval_hook)
575+
@eval_hooks_runner.add_hook(eval_hook)
576+
end
577+
578+
# Clears all eval hooks from the client
579+
# @return [void]
580+
def clear_eval_hooks
581+
@eval_hooks_runner.clear_hooks
582+
end
529583
end
530584

531585
# @deprecated Use `DevCycle::Client` instead.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
require 'devcycle-ruby-server-sdk/models/eval_hook'
2+
require 'devcycle-ruby-server-sdk/models/eval_hook_context'
3+
4+
module DevCycle
5+
# Custom error raised when a before hook fails
6+
class BeforeHookError < StandardError
7+
attr_reader :original_error, :hook_context
8+
9+
def initialize(message = nil, original_error = nil, hook_context = nil)
10+
super(message || "Before hook execution failed")
11+
@original_error = original_error
12+
@hook_context = hook_context
13+
end
14+
15+
def to_s
16+
msg = super
17+
msg += "\nOriginal error: #{@original_error.message}" if @original_error
18+
msg
19+
end
20+
end
21+
22+
# Custom error raised when an after hook fails
23+
class AfterHookError < StandardError
24+
attr_reader :original_error, :hook_context
25+
26+
def initialize(message = nil, original_error = nil, hook_context = nil)
27+
super(message || "After hook execution failed")
28+
@original_error = original_error
29+
@hook_context = hook_context
30+
end
31+
32+
def to_s
33+
msg = super
34+
msg += "\nOriginal error: #{@original_error.message}" if @original_error
35+
msg
36+
end
37+
end
38+
39+
class EvalHooksRunner
40+
# @return [Array<EvalHook>] Array of eval hooks to run
41+
attr_reader :eval_hooks
42+
43+
# Initializes the EvalHooksRunner with an optional array of eval hooks
44+
# @param [Array<EvalHook>, nil] eval_hooks Array of eval hooks to run
45+
def initialize(eval_hooks = [])
46+
@eval_hooks = eval_hooks || []
47+
end
48+
49+
# Runs all before hooks with the given context
50+
# @param [HookContext] context The context to pass to the hooks
51+
# @return [HookContext] The potentially modified context
52+
# @raise [BeforeHookError] when a before hook fails
53+
def run_before_hooks(context)
54+
current_context = context
55+
56+
@eval_hooks.each do |hook|
57+
next unless hook.before
58+
59+
begin
60+
result = hook.before.call(current_context)
61+
# If the hook returns a new context, use it for subsequent hooks
62+
current_context = result if result.is_a?(DevCycle::HookContext)
63+
rescue => e
64+
# Log error but continue with next hook
65+
warn "Error in before hook: #{e.message}"
66+
end
67+
end
68+
69+
current_context
70+
end
71+
72+
# Runs all after hooks with the given context
73+
# @param [HookContext] context The context to pass to the hooks
74+
# @return [void]
75+
# @raise [AfterHookError] when an after hook fails
76+
def run_after_hooks(context)
77+
@eval_hooks.each do |hook|
78+
next unless hook.after
79+
80+
begin
81+
hook.after.call(context)
82+
rescue => e
83+
# Log error but continue with next hook
84+
warn "Error in after hook: #{e.message}"
85+
end
86+
end
87+
end
88+
89+
# Runs all finally hooks with the given context
90+
# @param [HookContext] context The context to pass to the hooks
91+
# @return [void]
92+
def run_finally_hooks(context)
93+
@eval_hooks.each do |hook|
94+
next unless hook.on_finally
95+
96+
begin
97+
hook.on_finally.call(context)
98+
rescue => e
99+
# Log error but don't re-raise to prevent blocking evaluation
100+
warn "Error in finally hook: #{e.message}"
101+
end
102+
end
103+
end
104+
105+
# Runs all error hooks with the given context and error
106+
# @param [HookContext] context The context to pass to the hooks
107+
# @param [Exception] error The error that occurred
108+
# @return [void]
109+
def run_error_hooks(context, error)
110+
@eval_hooks.each do |hook|
111+
next unless hook.error
112+
113+
begin
114+
hook.error.call(context, error)
115+
rescue => e
116+
# Log error but don't re-raise to prevent blocking evaluation
117+
warn "Error in error hook: #{e.message}"
118+
end
119+
end
120+
end
121+
122+
# Adds an eval hook to the runner
123+
# @param [EvalHook] eval_hook The eval hook to add
124+
# @return [void]
125+
def add_hook(eval_hook)
126+
@eval_hooks << eval_hook
127+
end
128+
129+
# Clears all eval hooks from the runner
130+
# @return [void]
131+
def clear_hooks
132+
@eval_hooks.clear
133+
end
134+
end
135+
end
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
module DevCycle
2+
class EvalHook
3+
# Callback to be executed before evaluation
4+
attr_accessor :before
5+
6+
# Callback to be executed after evaluation
7+
attr_accessor :after
8+
9+
# Callback to be executed finally (always runs)
10+
attr_accessor :on_finally
11+
12+
# Callback to be executed on error
13+
attr_accessor :error
14+
15+
# Initializes the object with optional callback functions
16+
# @param [Hash] callbacks Callback functions in the form of hash
17+
# @option callbacks [Proc, nil] :before Callback to execute before evaluation
18+
# @option callbacks [Proc, nil] :after Callback to execute after evaluation
19+
# @option callbacks [Proc, nil] :on_finally Callback to execute finally (always runs)
20+
# @option callbacks [Proc, nil] :error Callback to execute on error
21+
def initialize(callbacks = {})
22+
@before = callbacks[:before]
23+
@after = callbacks[:after]
24+
@on_finally = callbacks[:on_finally]
25+
@error = callbacks[:error]
26+
end
27+
end
28+
end
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
module DevCycle
2+
class HookContext
3+
# The key of the variable being evaluated
4+
attr_accessor :key
5+
6+
# The user for whom the variable is being evaluated
7+
attr_accessor :user
8+
9+
# The default value for the variable
10+
attr_accessor :default_value
11+
12+
# Initializes the object
13+
# @param [String] key The key of the variable being evaluated
14+
# @param [DevCycle::User] user The user for whom the variable is being evaluated
15+
# @param [Object] default_value The default value for the variable
16+
def initialize(key:, user:, default_value:)
17+
@key = key
18+
@user = user
19+
@default_value = default_value
20+
end
21+
end
22+
end

0 commit comments

Comments
 (0)