Skip to content

feat: Add initial support for hooks #256

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
85 changes: 85 additions & 0 deletions lib/ldclient-rb/interfaces.rb
Original file line number Diff line number Diff line change
Expand Up @@ -885,5 +885,90 @@ 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking if it would be possible to force implementation of metadata. But it would probably just end up like the JS implementation. Where ultimately we end up falling back anyway.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could raise an exception. That would force people to implement it. Want me to make that change?

Metadata.new('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 evaluation_series_context [EvaluationSeriesContext] 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(evaluation_series_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 evaluation_series_context [EvaluationSeriesContext] 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(evaluation_series_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 EvaluationSeriesContext
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
131 changes: 129 additions & 2 deletions lib/ldclient-rb/ldclient.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
require "ldclient-rb/impl/flag_tracker"
require "ldclient-rb/impl/store_client_wrapper"
require "ldclient-rb/impl/migrations/tracker"
require "concurrent"
require "concurrent/atomics"
require "digest/sha1"
require "forwardable"
Expand Down Expand Up @@ -54,6 +55,7 @@ def initialize(sdk_key, config = Config.default, wait_for_sec = 5)
end

@sdk_key = sdk_key
@hooks = Concurrent::Array.new(config.hooks)

@shared_executor = Concurrent::SingleThreadExecutor.new

Expand Down Expand Up @@ -131,6 +133,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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember, does ruby have threading concerns?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like your node implementation, I make a copy of the hooks before I begin processing, so I don't think there is a thread issue here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. I wasn't sure if you actually had to lock the collection. In node there aren't threads, so it doesn't have to worry about any memory synchronization stuff.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually in thinking about it more, I do need to do some locking. This would work fine in MRI, but jRuby would probably have some sync issues since it doesn't use a GIL.

Updated to use the concurrent package we have used elsewhere.

end

#
# Tells the client that all pending analytics events should be delivered as soon as possible.
#
Expand Down Expand Up @@ -226,10 +245,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, evaluation_series_context = prepare_hooks(key, context, default, method)
hook_data = execute_before_evaluation(hooks, evaluation_series_context)
evaluation_detail, flag, error = yield
execute_after_evaluation(hooks, evaluation_series_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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this nil propagate to the after?

Copy link
Member Author

@keelerm84 keelerm84 Mar 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The results of this method get returned as an array. We then use that array in the after_evaluation .zip call to pair it with the appropriate hook.

#
# @param hooks [Array<Interfaces::Hooks::Hook>]
# @param evaluation_series_context [EvaluationSeriesContext]
#
# @return [Array<any>]
#
private def execute_before_evaluation(hooks, evaluation_series_context)
hooks.map do |hook|
try_execute_stage(:before_evaluation, hook.metadata.name) do
hook.before_evaluation(evaluation_series_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 evaluation_series_context [EvaluationSeriesContext]
# @param hook_data [Array<any>]
# @param evaluation_detail [EvaluationDetail]
#
# @return [Array<any>]
#
private def execute_after_evaluation(hooks, evaluation_series_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(evaluation_series_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 EvaluationSeriesContext 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::EvaluationSeriesContext]]
#
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
evaluation_series_context = Interfaces::Hooks::EvaluationSeriesContext.new(key, context, default, method)

[hooks, evaluation_series_context]
end

#
# This method returns the migration stage of the migration feature flag for the given evaluation context.
#
Expand Down Expand Up @@ -508,7 +633,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