Skip to content

Commit 3cf16eb

Browse files
committed
feat: Add initial support for hooks (#256)
1 parent 6f6597c commit 3cf16eb

File tree

5 files changed

+400
-2
lines changed

5 files changed

+400
-2
lines changed

lib/ldclient-rb/config.rb

+13
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class Config
4343
# @option opts [BigSegmentsConfig] :big_segments See {#big_segments}.
4444
# @option opts [Hash] :application See {#application}
4545
# @option opts [String] :payload_filter_key See {#payload_filter_key}
46+
# @option hooks [Array<Interfaces::Hooks::Hook]
4647
#
4748
def initialize(opts = {})
4849
@base_uri = (opts[:base_uri] || Config.default_base_uri).chomp("/")
@@ -75,6 +76,7 @@ def initialize(opts = {})
7576
@big_segments = opts[:big_segments] || BigSegmentsConfig.new(store: nil)
7677
@application = LaunchDarkly::Impl::Util.validate_application_info(opts[:application] || {}, @logger)
7778
@payload_filter_key = opts[:payload_filter_key]
79+
@hooks = (opts[:hooks] || []).keep_if { |hook| hook.is_a? Interfaces::Hooks::Hook }
7880
@data_source_update_sink = nil
7981
end
8082

@@ -372,6 +374,17 @@ def diagnostic_opt_out?
372374
#
373375
attr_reader :socket_factory
374376

377+
#
378+
# Initial set of hooks for the client.
379+
#
380+
# Hooks provide entrypoints which allow for observation of SDK functions.
381+
#
382+
# LaunchDarkly provides integration packages, and most applications will not
383+
# need to implement their own hooks. Refer to the `launchdarkly-server-sdk-otel` gem
384+
# for instrumentation.
385+
#
386+
attr_reader :hooks
387+
375388
#
376389
# The default LaunchDarkly client configuration. This configuration sets
377390
# reasonable defaults for most users.

lib/ldclient-rb/interfaces.rb

+85
Original file line numberDiff line numberDiff line change
@@ -885,5 +885,90 @@ def build
885885
end
886886
end
887887
end
888+
889+
module Hooks
890+
#
891+
# Mixin for extending SDK functionality via hooks.
892+
#
893+
# All provided hook implementations **MUST** include this mixin. Hooks without this mixin will be ignored.
894+
#
895+
# This mixin includes default implementations for all hook handlers. This allows LaunchDarkly to expand the list
896+
# of hook handlers without breaking customer integrations.
897+
#
898+
module Hook
899+
#
900+
# Get metadata about the hook implementation.
901+
#
902+
# @return [Metadata]
903+
#
904+
def metadata
905+
Metadata.new('UNDEFINED')
906+
end
907+
908+
#
909+
# The before method is called during the execution of a variation method before the flag value has been
910+
# determined. The method is executed synchronously.
911+
#
912+
# @param evaluation_series_context [EvaluationSeriesContext] Contains information about the evaluation being
913+
# performed. This is not mutable.
914+
# @param data [Hash] A record associated with each stage of hook invocations. Each stage is called with the data
915+
# of the previous stage for a series. The input record should not be modified.
916+
# @return [Hash] Data to use when executing the next state of the hook in the evaluation series.
917+
#
918+
def before_evaluation(evaluation_series_context, data)
919+
{}
920+
end
921+
922+
#
923+
# The after method is called during the execution of the variation method # after the flag value has been
924+
# determined. The method is executed synchronously.
925+
#
926+
# @param evaluation_series_context [EvaluationSeriesContext] Contains read-only information about the evaluation
927+
# being performed.
928+
# @param data [Hash] A record associated with each stage of hook invocations. Each stage is called with the data
929+
# of the previous stage for a series.
930+
# @param detail [LaunchDarkly::EvaluationDetail] The result of the evaluation. This value should not be
931+
# modified.
932+
# @return [Hash] Data to use when executing the next state of the hook in the evaluation series.
933+
#
934+
def after_evaluation(evaluation_series_context, data, detail)
935+
data
936+
end
937+
end
938+
939+
#
940+
# Metadata data class used for annotating hook implementations.
941+
#
942+
class Metadata
943+
attr_reader :name
944+
945+
def initialize(name)
946+
@name = name
947+
end
948+
end
949+
950+
#
951+
# Contextual information that will be provided to handlers during evaluation series.
952+
#
953+
class EvaluationSeriesContext
954+
attr_reader :key
955+
attr_reader :context
956+
attr_reader :value
957+
attr_reader :method
958+
959+
#
960+
# @param key [String]
961+
# @param context [LaunchDarkly::LDContext]
962+
# @param value [any]
963+
# @param method [Symbol]
964+
#
965+
def initialize(key, context, value, method)
966+
@key = key
967+
@context = context
968+
@value = value
969+
@method = method
970+
end
971+
end
972+
end
888973
end
889974
end

lib/ldclient-rb/ldclient.rb

+129-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
require "ldclient-rb/impl/flag_tracker"
88
require "ldclient-rb/impl/store_client_wrapper"
99
require "ldclient-rb/impl/migrations/tracker"
10+
require "concurrent"
1011
require "concurrent/atomics"
1112
require "digest/sha1"
1213
require "forwardable"
@@ -54,6 +55,7 @@ def initialize(sdk_key, config = Config.default, wait_for_sec = 5)
5455
end
5556

5657
@sdk_key = sdk_key
58+
@hooks = Concurrent::Array.new(config.hooks)
5759

5860
@shared_executor = Concurrent::SingleThreadExecutor.new
5961

@@ -131,6 +133,23 @@ def initialize(sdk_key, config = Config.default, wait_for_sec = 5)
131133
end
132134
end
133135

136+
#
137+
# Add a hook to the client. In order to register a hook before the client starts, please use the `hooks` property of
138+
# {#LDConfig}.
139+
#
140+
# Hooks provide entrypoints which allow for observation of SDK functions.
141+
#
142+
# @param hook [Interfaces::Hooks::Hook]
143+
#
144+
def add_hook(hook)
145+
unless hook.is_a?(Interfaces::Hooks::Hook)
146+
@config.logger.error { "[LDClient] Attempted to add a hook that does not include the LaunchDarkly::Intefaces::Hooks::Hook mixin. Ignoring." }
147+
return
148+
end
149+
150+
@hooks.push(hook)
151+
end
152+
134153
#
135154
# Tells the client that all pending analytics events should be delivered as soon as possible.
136155
#
@@ -226,10 +245,116 @@ def variation(key, context, default)
226245
# @return [EvaluationDetail] an object describing the result
227246
#
228247
def variation_detail(key, context, default)
229-
detail, _, _ = evaluate_internal(key, context, default, true)
248+
detail, _, _ = evaluate_with_hooks(key, context, default, :variation_detail) do
249+
evaluate_internal(key, context, default, true)
250+
end
251+
230252
detail
231253
end
232254

255+
#
256+
# evaluate_with_hook will run the provided block, wrapping it with evaluation hook support.
257+
#
258+
# Example:
259+
#
260+
# ```ruby
261+
# evaluate_with_hooks(key, context, default, method) do
262+
# puts 'This is being wrapped with evaluation hooks'
263+
# end
264+
# ```
265+
#
266+
# @param key [String]
267+
# @param context [String]
268+
# @param default [String]
269+
# @param method [Symbol]
270+
# @param &block [#call] Implicit passed block
271+
#
272+
private def evaluate_with_hooks(key, context, default, method)
273+
return yield if @hooks.empty?
274+
275+
hooks, evaluation_series_context = prepare_hooks(key, context, default, method)
276+
hook_data = execute_before_evaluation(hooks, evaluation_series_context)
277+
evaluation_detail, flag, error = yield
278+
execute_after_evaluation(hooks, evaluation_series_context, hook_data, evaluation_detail)
279+
280+
[evaluation_detail, flag, error]
281+
end
282+
283+
#
284+
# Execute the :before_evaluation stage of the evaluation series.
285+
#
286+
# This method will return the results of each hook, indexed into an array in the same order as the hooks. If a hook
287+
# raised an uncaught exception, the value will be nil.
288+
#
289+
# @param hooks [Array<Interfaces::Hooks::Hook>]
290+
# @param evaluation_series_context [EvaluationSeriesContext]
291+
#
292+
# @return [Array<any>]
293+
#
294+
private def execute_before_evaluation(hooks, evaluation_series_context)
295+
hooks.map do |hook|
296+
try_execute_stage(:before_evaluation, hook.metadata.name) do
297+
hook.before_evaluation(evaluation_series_context, {})
298+
end
299+
end
300+
end
301+
302+
#
303+
# Execute the :after_evaluation stage of the evaluation series.
304+
#
305+
# This method will return the results of each hook, indexed into an array in the same order as the hooks. If a hook
306+
# raised an uncaught exception, the value will be nil.
307+
#
308+
# @param hooks [Array<Interfaces::Hooks::Hook>]
309+
# @param evaluation_series_context [EvaluationSeriesContext]
310+
# @param hook_data [Array<any>]
311+
# @param evaluation_detail [EvaluationDetail]
312+
#
313+
# @return [Array<any>]
314+
#
315+
private def execute_after_evaluation(hooks, evaluation_series_context, hook_data, evaluation_detail)
316+
hooks.zip(hook_data).reverse.map do |(hook, data)|
317+
try_execute_stage(:after_evaluation, hook.metadata.name) do
318+
hook.after_evaluation(evaluation_series_context, data, evaluation_detail)
319+
end
320+
end
321+
end
322+
323+
#
324+
# Try to execute the provided block. If execution raises an exception, catch and log it, then move on with
325+
# execution.
326+
#
327+
# @return [any]
328+
#
329+
private def try_execute_stage(method, hook_name)
330+
begin
331+
yield
332+
rescue => e
333+
@config.logger.error { "[LDClient] An error occurred in #{method} of the hook #{hook_name}: #{e}" }
334+
nil
335+
end
336+
end
337+
338+
#
339+
# Return a copy of the existing hooks and a few instance of the EvaluationSeriesContext used for the evaluation series.
340+
#
341+
# @param key [String]
342+
# @param context [LDContext]
343+
# @param default [any]
344+
# @param method [Symbol]
345+
# @return [Array[Array<Interfaces::Hooks::Hook>, Interfaces::Hooks::EvaluationSeriesContext]]
346+
#
347+
private def prepare_hooks(key, context, default, method)
348+
# Copy the hooks to use a consistent set during the evaluation series.
349+
#
350+
# Hooks can be added and we want to ensure all correct stages for a given hook execute. For example, we do not
351+
# want to trigger the after_evaluation method without also triggering the before_evaluation method.
352+
hooks = @hooks.dup
353+
evaluation_series_context = Interfaces::Hooks::EvaluationSeriesContext.new(key, context, default, method)
354+
355+
[hooks, evaluation_series_context]
356+
end
357+
233358
#
234359
# This method returns the migration stage of the migration feature flag for the given evaluation context.
235360
#
@@ -508,7 +633,9 @@ def create_default_data_source(sdk_key, config, diagnostic_accumulator)
508633
# @return [Array<EvaluationDetail, [LaunchDarkly::Impl::Model::FeatureFlag, nil], [String, nil]>]
509634
#
510635
def variation_with_flag(key, context, default)
511-
evaluate_internal(key, context, default, false)
636+
evaluate_with_hooks(key, context, default, :variation_detail) do
637+
evaluate_internal(key, context, default, false)
638+
end
512639
end
513640

514641
#

0 commit comments

Comments
 (0)