Skip to content

Commit

Permalink
feat: Add initial support for hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
keelerm84 committed Mar 20, 2024
1 parent 389953b commit 2459500
Show file tree
Hide file tree
Showing 5 changed files with 398 additions and 2 deletions.
13 changes: 13 additions & 0 deletions lib/ldclient-rb/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class Config
# @option opts [BigSegmentsConfig] :big_segments See {#big_segments}.
# @option opts [Hash] :application See {#application}
# @option opts [String] :payload_filter_key See {#payload_filter_key}
# @option hooks [Array<Interfaces::Hooks::Hook]
#
def initialize(opts = {})
@base_uri = (opts[:base_uri] || Config.default_base_uri).chomp("/")
Expand Down Expand Up @@ -75,6 +76,7 @@ def initialize(opts = {})
@big_segments = opts[:big_segments] || BigSegmentsConfig.new(store: nil)
@application = LaunchDarkly::Impl::Util.validate_application_info(opts[:application] || {}, @logger)
@payload_filter_key = opts[:payload_filter_key]
@hooks = (opts[:hooks] || []).keep_if { |hook| hook.is_a? Interfaces::Hooks::Hook }
@data_source_update_sink = nil
end

Expand Down Expand Up @@ -372,6 +374,17 @@ def diagnostic_opt_out?
#
attr_reader :socket_factory

#
# Initial set of hooks for the client.
#
# Hooks provide entrypoints which allow for observation of SDK functions.
#
# LaunchDarkly provides integration packages, and most applications will not
# need to implement their own hooks. Refer to the `launchdarkly-server-sdk-otel` gem
# for instrumentation.
#
attr_reader :hooks

#
# The default LaunchDarkly client configuration. This configuration sets
# reasonable defaults for most users.
Expand Down
84 changes: 84 additions & 0 deletions lib/ldclient-rb/interfaces.rb
Original file line number Diff line number Diff line change
Expand Up @@ -885,5 +885,89 @@ def build
end
end
end

module Hooks
#
# Mixin for extending SDK functionality via hooks.
#
# All provided hook implementations **MUST** include this mixin. Hooks without this mixin will be ignored.
#
# This mixin includes default implementations for all hook handlers. This allows LaunchDarkly to expand the list
# of hook handlers without breaking customer integrations.
#
module Hook
#
# Get metadata about the hook implementation.
#
# @return [Metadata]
#
def metadata
Metadata('UNDEFINED')
end

#
# The before method is called during the execution of a variation method before the flag value has been
# determined. The method is executed synchronously.
#
# @param hook_context [EvaluationContext] Contains information about the evaluation being performed. This is not
# mutable.
# @param data [Hash] A record associated with each stage of hook invocations. Each stage is called with the data
# of the previous stage for a series. The input record should not be modified.
# @return [Hash] Data to use when executing the next state of the hook in the evaluation series.
#
def before_evaluation(hook_context, data)
{}
end

#
# The after method is called during the execution of the variation method
# after the flag value has been determined. The method is executed synchronously.
#
# @param hook_context [EvaluationContext] Contains read-only information about the evaluation being performed.
# @param data [Hash] A record associated with each stage of hook invocations. Each stage is called with the data
# of the previous stage for a series.
# @param detail [LaunchDarkly::EvaluationDetail] The result of the evaluation. This value should not be
# modified.
# @return [Hash] Data to use when executing the next state of the hook in the evaluation series.
#
def after_evaluation(hook_context, data, detail)
data
end
end

#
# Metadata data class used for annotating hook implementations.
#
class Metadata
attr_reader :name

def initialize(name)
@name = name
end
end

#
# Contextual information that will be provided to handlers during evaluation series.
#
class EvaluationContext
attr_reader :key
attr_reader :context
attr_reader :value
attr_reader :method

#
# @param key [String]
# @param context [LaunchDarkly::LDContext]
# @param value [any]
# @param method [Symbol]
#
def initialize(key, context, value, method)
@key = key
@context = context
@value = value
@method = method
end
end
end
end
end
130 changes: 128 additions & 2 deletions lib/ldclient-rb/ldclient.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def initialize(sdk_key, config = Config.default, wait_for_sec = 5)
end

@sdk_key = sdk_key
@hooks = config.hooks

@shared_executor = Concurrent::SingleThreadExecutor.new

Expand Down Expand Up @@ -131,6 +132,23 @@ def initialize(sdk_key, config = Config.default, wait_for_sec = 5)
end
end

#
# Add a hook to the client. In order to register a hook before the client starts, please use the `hooks` property of
# {#LDConfig}.
#
# Hooks provide entrypoints which allow for observation of SDK functions.
#
# @param hook [Interfaces::Hooks::Hook]
#
def add_hook(hook)
unless hook.is_a?(Interfaces::Hooks::Hook)
@config.logger.error { "[LDClient] Attempted to add a hook that does not include the LaunchDarkly::Intefaces::Hooks:Hook mixin. Ignoring." }
return
end

@hooks.push(hook)
end

#
# Tells the client that all pending analytics events should be delivered as soon as possible.
#
Expand Down Expand Up @@ -226,10 +244,116 @@ def variation(key, context, default)
# @return [EvaluationDetail] an object describing the result
#
def variation_detail(key, context, default)
detail, _, _ = evaluate_internal(key, context, default, true)
detail, _, _ = evaluate_with_hooks(key, context, default, :variation_detail) do
evaluate_internal(key, context, default, true)
end

detail
end

#
# evaluate_with_hook will run the provided block, wrapping it with evaluation hook support.
#
# Example:
#
# ```ruby
# evaluate_with_hooks(key, context, default, method) do
# puts 'This is being wrapped with evaluation hooks'
# end
# ```
#
# @param key [String]
# @param context [String]
# @param default [String]
# @param method [Symbol]
# @param &block [#call] Implicit passed block
#
private def evaluate_with_hooks(key, context, default, method)
return yield if @hooks.empty?

hooks, hook_context = prepare_hooks(key, context, default, method)
hook_data = execute_before_evaluation(hooks, hook_context)
evaluation_detail, flag, error = yield
execute_after_evaluation(hooks, hook_context, hook_data, evaluation_detail)

[evaluation_detail, flag, error]
end

#
# Execute the :before_evaluation stage of the evaluation series.
#
# This method will return the results of each hook, indexed into an array in the same order as the hooks. If a hook
# raised an uncaught exception, the value will be nil.
#
# @param hooks [Array<Interfaces::Hooks::Hook>]
# @param hook_context [EvaluationContext]
#
# @return [Array<any>]
#
private def execute_before_evaluation(hooks, hook_context)
hooks.map do |hook|
try_execute_stage(:before_evaluation, hook.metadata.name) do
hook.before_evaluation(hook_context, {})
end
end
end

#
# Execute the :after_evaluation stage of the evaluation series.
#
# This method will return the results of each hook, indexed into an array in the same order as the hooks. If a hook
# raised an uncaught exception, the value will be nil.
#
# @param hooks [Array<Interfaces::Hooks::Hook>]
# @param hook_context [EvaluationContext]
# @param hook_data [Array<any>]
# @param evaluation_detail [EvaluationDetail]
#
# @return [Array<any>]
#
private def execute_after_evaluation(hooks, hook_context, hook_data, evaluation_detail)
hooks.zip(hook_data).reverse.map do |(hook, data)|
try_execute_stage(:after_evaluation, hook.metadata.name) do
hook.after_evaluation(hook_context, data, evaluation_detail)
end
end
end

#
# Try to execute the provided block. If execution raises an exception, catch and log it, then move on with
# execution.
#
# @return [any]
#
private def try_execute_stage(method, hook_name)
begin
yield
rescue => e
@config.logger.error { "[LDClient] An error occurred in #{method} of the hook #{hook_name}: #{e}" }
nil
end
end

#
# Return a copy of the existing hooks and a few instance of the EvaluationContext used for the evaluation series.
#
# @param key [String]
# @param context [LDContext]
# @param default [any]
# @param method [Symbol]
# @return [Array[Array<Interfaces::Hooks::Hook>, Interfaces::Hooks::EvaluationContext]]
#
private def prepare_hooks(key, context, default, method)
# Copy the hooks to use a consistent set during the evaluation series.
#
# Hooks can be added and we want to ensure all correct stages for a given hook execute. For example, we do not
# want to trigger the after_evaluation method without also triggering the before_evaluation method.
hooks = @hooks.dup
hook_context = Interfaces::Hooks::EvaluationContext.new(key, context, default, method)

[hooks, hook_context]
end

#
# This method returns the migration stage of the migration feature flag for the given evaluation context.
#
Expand Down Expand Up @@ -508,7 +632,9 @@ def create_default_data_source(sdk_key, config, diagnostic_accumulator)
# @return [Array<EvaluationDetail, [LaunchDarkly::Impl::Model::FeatureFlag, nil], [String, nil]>]
#
def variation_with_flag(key, context, default)
evaluate_internal(key, context, default, false)
evaluate_with_hooks(key, context, default, :variation_detail) do
evaluate_internal(key, context, default, false)
end
end

#
Expand Down
Loading

0 comments on commit 2459500

Please sign in to comment.