axn 0.1.0.pre.alpha.3 → 0.1.0.pre.alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.cursor/commands/pr.md +36 -0
- data/CHANGELOG.md +15 -1
- data/Rakefile +102 -2
- data/docs/.vitepress/config.mjs +12 -8
- data/docs/advanced/conventions.md +1 -1
- data/docs/advanced/mountable.md +4 -90
- data/docs/advanced/profiling.md +26 -30
- data/docs/advanced/rough.md +27 -8
- data/docs/intro/overview.md +1 -1
- data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
- data/docs/recipes/memoization.md +102 -17
- data/docs/reference/async.md +269 -0
- data/docs/reference/class.md +113 -50
- data/docs/reference/configuration.md +226 -75
- data/docs/reference/form-object.md +252 -0
- data/docs/strategies/client.md +212 -0
- data/docs/strategies/form.md +235 -0
- data/docs/usage/setup.md +2 -2
- data/docs/usage/writing.md +99 -1
- data/lib/axn/async/adapters/active_job.rb +19 -10
- data/lib/axn/async/adapters/disabled.rb +15 -0
- data/lib/axn/async/adapters/sidekiq.rb +25 -32
- data/lib/axn/async/batch_enqueue/config.rb +38 -0
- data/lib/axn/async/batch_enqueue.rb +99 -0
- data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
- data/lib/axn/async.rb +121 -4
- data/lib/axn/configuration.rb +53 -13
- data/lib/axn/context.rb +1 -0
- data/lib/axn/core/automatic_logging.rb +47 -51
- data/lib/axn/core/context/facade_inspector.rb +1 -1
- data/lib/axn/core/contract.rb +73 -30
- data/lib/axn/core/contract_for_subfields.rb +1 -1
- data/lib/axn/core/contract_validation.rb +14 -9
- data/lib/axn/core/contract_validation_for_subfields.rb +14 -7
- data/lib/axn/core/default_call.rb +63 -0
- data/lib/axn/core/flow/exception_execution.rb +5 -0
- data/lib/axn/core/flow/handlers/descriptors/message_descriptor.rb +19 -7
- data/lib/axn/core/flow/handlers/invoker.rb +4 -30
- data/lib/axn/core/flow/handlers/matcher.rb +4 -14
- data/lib/axn/core/flow/messages.rb +1 -1
- data/lib/axn/core/hooks.rb +1 -0
- data/lib/axn/core/logging.rb +16 -5
- data/lib/axn/core/memoization.rb +53 -0
- data/lib/axn/core/tracing.rb +77 -4
- data/lib/axn/core/validation/validators/type_validator.rb +1 -1
- data/lib/axn/core.rb +31 -46
- data/lib/axn/extras/strategies/client.rb +150 -0
- data/lib/axn/extras/strategies/vernier.rb +121 -0
- data/lib/axn/extras.rb +4 -0
- data/lib/axn/factory.rb +22 -2
- data/lib/axn/form_object.rb +90 -0
- data/lib/axn/internal/logging.rb +5 -1
- data/lib/axn/mountable/helpers/class_builder.rb +41 -10
- data/lib/axn/mountable/helpers/namespace_manager.rb +6 -34
- data/lib/axn/mountable/inherit_profiles.rb +2 -2
- data/lib/axn/mountable/mounting_strategies/_base.rb +10 -6
- data/lib/axn/mountable/mounting_strategies/method.rb +2 -2
- data/lib/axn/mountable.rb +41 -7
- data/lib/axn/rails/generators/axn_generator.rb +19 -1
- data/lib/axn/rails/generators/templates/action.rb.erb +1 -1
- data/lib/axn/result.rb +2 -2
- data/lib/axn/strategies/form.rb +98 -0
- data/lib/axn/strategies/transaction.rb +7 -0
- data/lib/axn/util/callable.rb +120 -0
- data/lib/axn/util/contract_error_handling.rb +32 -0
- data/lib/axn/util/execution_context.rb +34 -0
- data/lib/axn/util/global_id_serialization.rb +52 -0
- data/lib/axn/util/logging.rb +87 -0
- data/lib/axn/version.rb +1 -1
- data/lib/axn.rb +9 -0
- metadata +22 -4
- data/lib/axn/core/profiling.rb +0 -124
- data/lib/axn/mountable/mounting_strategies/enqueue_all.rb +0 -55
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Core
|
|
5
|
+
module Memoization
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.class_eval do
|
|
8
|
+
extend ClassMethods
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def memo(method_name)
|
|
14
|
+
if _memo_wise_available?
|
|
15
|
+
_ensure_memo_wise_prepended
|
|
16
|
+
memo_wise(method_name)
|
|
17
|
+
else
|
|
18
|
+
_memo_minimal(method_name)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def _memo_wise_available?
|
|
25
|
+
defined?(MemoWise)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def _ensure_memo_wise_prepended
|
|
29
|
+
return if ancestors.include?(MemoWise)
|
|
30
|
+
|
|
31
|
+
prepend MemoWise
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def _memo_minimal(method_name)
|
|
35
|
+
method = instance_method(method_name)
|
|
36
|
+
params = method.parameters
|
|
37
|
+
has_args = params.any? { |type, _name| %i[req opt rest keyreq key keyrest].include?(type) }
|
|
38
|
+
|
|
39
|
+
if has_args
|
|
40
|
+
raise ArgumentError,
|
|
41
|
+
"Memoization of methods with arguments requires the 'memo_wise' gem. " \
|
|
42
|
+
"Please add 'memo_wise' to your Gemfile or use a method without arguments."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Wrap the method with memoization
|
|
46
|
+
Axn::Util::Memoization.define_memoized_reader_method(self, method_name) do
|
|
47
|
+
method.bind(self).call
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/axn/core/tracing.rb
CHANGED
|
@@ -1,16 +1,89 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module Axn
|
|
4
6
|
module Core
|
|
5
7
|
module Tracing
|
|
8
|
+
class << self
|
|
9
|
+
# Cache the tracer instance to avoid repeated lookups
|
|
10
|
+
# The tracer provider may cache internally, but we avoid the method call overhead
|
|
11
|
+
# We check defined?(OpenTelemetry) each time to handle cases where it's loaded lazily
|
|
12
|
+
def tracer
|
|
13
|
+
return nil unless defined?(OpenTelemetry)
|
|
14
|
+
|
|
15
|
+
# Re-fetch if the tracer provider has changed (e.g., in tests with mocks)
|
|
16
|
+
current_provider = OpenTelemetry.tracer_provider
|
|
17
|
+
return @tracer if defined?(@tracer) && defined?(@tracer_provider) && @tracer_provider == current_provider
|
|
18
|
+
|
|
19
|
+
@tracer_provider = current_provider
|
|
20
|
+
@tracer = current_provider.tracer("axn", Axn::VERSION)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
6
24
|
private
|
|
7
25
|
|
|
8
26
|
def _with_tracing(&)
|
|
9
|
-
|
|
27
|
+
resource = self.class.name || "AnonymousClass"
|
|
28
|
+
payload = { resource:, action: self }
|
|
29
|
+
|
|
30
|
+
update_payload = proc do
|
|
31
|
+
result = self.result
|
|
32
|
+
outcome = result.outcome.to_s
|
|
33
|
+
payload[:outcome] = outcome
|
|
34
|
+
payload[:result] = result
|
|
35
|
+
payload[:elapsed_time] = result.elapsed_time
|
|
36
|
+
payload[:exception] = result.exception if result.exception
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
# Don't raise in ensure block to avoid interfering with existing exceptions
|
|
39
|
+
Axn::Internal::Logging.piping_error("updating notification payload while tracing axn.call", action: self, exception: e)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
instrument_block = proc do
|
|
43
|
+
ActiveSupport::Notifications.instrument("axn.call", payload, &)
|
|
44
|
+
ensure
|
|
45
|
+
# Update payload BEFORE instrument completes so subscribers see the changes
|
|
46
|
+
update_payload.call
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# NOTE: despite using block form, ActiveSupport explicitly only emits to subscribers when it's finished,
|
|
50
|
+
# which means it's not suitable for wrapping execution with a span and tracking child spans.
|
|
51
|
+
# We use OpenTelemetry for that, if available.
|
|
52
|
+
if defined?(OpenTelemetry)
|
|
53
|
+
Tracing.tracer.in_span("axn.call", attributes: { "axn.resource" => resource }) do |span|
|
|
54
|
+
instrument_block.call
|
|
55
|
+
ensure
|
|
56
|
+
# Update span with outcome and error status after execution
|
|
57
|
+
# This ensure runs before the span finishes, so we can still update it
|
|
58
|
+
begin
|
|
59
|
+
result = self.result
|
|
60
|
+
outcome = result.outcome.to_s
|
|
61
|
+
span.set_attribute("axn.outcome", outcome)
|
|
10
62
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
63
|
+
if %w[failure exception].include?(outcome) && result.exception
|
|
64
|
+
span.record_exception(result.exception)
|
|
65
|
+
error_message = result.exception.message || result.exception.class.name
|
|
66
|
+
span.status = OpenTelemetry::Trace::Status.error(error_message)
|
|
67
|
+
end
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
# Don't raise in ensure block to avoid interfering with existing exceptions
|
|
70
|
+
Axn::Internal::Logging.piping_error("updating OTel span while tracing axn.call", action: self, exception: e)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
else
|
|
74
|
+
instrument_block.call
|
|
75
|
+
end
|
|
76
|
+
ensure
|
|
77
|
+
begin
|
|
78
|
+
emit_metrics_proc = Axn.config.emit_metrics
|
|
79
|
+
if emit_metrics_proc
|
|
80
|
+
result = self.result
|
|
81
|
+
Axn::Util::Callable.call_with_desired_shape(emit_metrics_proc, kwargs: { resource:, result: })
|
|
82
|
+
end
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
# Don't raise in ensure block to avoid interfering with existing exceptions
|
|
85
|
+
Axn::Internal::Logging.piping_error("calling emit_metrics while tracing axn.call", action: self, exception: e)
|
|
86
|
+
end
|
|
14
87
|
end
|
|
15
88
|
end
|
|
16
89
|
end
|
|
@@ -39,7 +39,7 @@ module Axn
|
|
|
39
39
|
private
|
|
40
40
|
|
|
41
41
|
def types = Array(options[:klass])
|
|
42
|
-
def msg = types.size == 1 ? "is not a #{types.first}" : "is not one of #{types.join(
|
|
42
|
+
def msg = types.size == 1 ? "is not a #{types.first}" : "is not one of #{types.join(', ')}"
|
|
43
43
|
|
|
44
44
|
def valid_type?(type:, value:, allow_blank:)
|
|
45
45
|
# NOTE: allow mocks to pass type validation by default (much easier testing ergonomics)
|
data/lib/axn/core.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "axn/internal/logging"
|
|
|
5
5
|
require "axn/context"
|
|
6
6
|
|
|
7
7
|
require "axn/strategies"
|
|
8
|
+
require "axn/extras"
|
|
8
9
|
require "axn/core/hooks"
|
|
9
10
|
require "axn/core/logging"
|
|
10
11
|
require "axn/core/flow"
|
|
@@ -12,8 +13,8 @@ require "axn/core/automatic_logging"
|
|
|
12
13
|
require "axn/core/use_strategy"
|
|
13
14
|
require "axn/core/timing"
|
|
14
15
|
require "axn/core/tracing"
|
|
15
|
-
require "axn/core/profiling"
|
|
16
16
|
require "axn/core/nesting_tracking"
|
|
17
|
+
require "axn/core/memoization"
|
|
17
18
|
|
|
18
19
|
# CONSIDER: make class names match file paths?
|
|
19
20
|
require "axn/core/validation/validators/model_validator"
|
|
@@ -25,9 +26,27 @@ require "axn/core/contract_validation"
|
|
|
25
26
|
require "axn/core/contract_validation_for_subfields"
|
|
26
27
|
require "axn/core/contract"
|
|
27
28
|
require "axn/core/contract_for_subfields"
|
|
29
|
+
require "axn/core/default_call"
|
|
28
30
|
|
|
29
31
|
module Axn
|
|
30
32
|
module Core
|
|
33
|
+
module ClassMethods
|
|
34
|
+
def call(**)
|
|
35
|
+
new(**).tap(&:_run).result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call!(**)
|
|
39
|
+
result = call(**)
|
|
40
|
+
return result if result.ok?
|
|
41
|
+
|
|
42
|
+
# When we're nested, we want to raise a failure that includes the source action to support
|
|
43
|
+
# the error message generation's `from` filter
|
|
44
|
+
raise Axn::Failure.new(result.error, source: result.__action__), cause: result.exception if _nested_in_another_axn?
|
|
45
|
+
|
|
46
|
+
raise result.exception
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
31
50
|
def self.included(base)
|
|
32
51
|
base.class_eval do
|
|
33
52
|
extend ClassMethods
|
|
@@ -36,7 +55,6 @@ module Axn
|
|
|
36
55
|
include Core::AutomaticLogging
|
|
37
56
|
include Core::Tracing
|
|
38
57
|
include Core::Timing
|
|
39
|
-
include Core::Profiling
|
|
40
58
|
|
|
41
59
|
include Core::Flow
|
|
42
60
|
|
|
@@ -47,42 +65,21 @@ module Axn
|
|
|
47
65
|
include Core::NestingTracking
|
|
48
66
|
|
|
49
67
|
include Core::UseStrategy
|
|
68
|
+
include Core::Memoization
|
|
69
|
+
include Core::DefaultCall
|
|
50
70
|
end
|
|
51
71
|
end
|
|
52
72
|
|
|
53
|
-
module ClassMethods
|
|
54
|
-
def call(**)
|
|
55
|
-
new(**).tap(&:_run).result
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def call!(**)
|
|
59
|
-
result = call(**)
|
|
60
|
-
return result if result.ok?
|
|
61
|
-
|
|
62
|
-
# When we're nested, we want to raise a failure that includes the source action to support
|
|
63
|
-
# the error message generation's `from` filter
|
|
64
|
-
raise Axn::Failure.new(result.error, source: result.__action__), cause: result.exception if _nested_in_another_axn?
|
|
65
|
-
|
|
66
|
-
raise result.exception
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def initialize(**)
|
|
71
|
-
@__context = Axn::Context.new(**)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
73
|
# Main entry point for action execution
|
|
75
74
|
def _run
|
|
76
75
|
_tracking_nesting(self) do
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
call
|
|
85
|
-
end
|
|
76
|
+
_with_tracing do
|
|
77
|
+
_with_logging do
|
|
78
|
+
_with_timing do
|
|
79
|
+
_with_exception_handling do # Exceptions stop here; outer wrappers access result status (and must not introduce another exception layer)
|
|
80
|
+
_with_contract do # Library internals -- any failures (e.g. contract violations) *should* fail the Action::Result
|
|
81
|
+
_with_hooks do # User hooks -- any failures here *should* fail the Action::Result
|
|
82
|
+
call
|
|
86
83
|
end
|
|
87
84
|
end
|
|
88
85
|
end
|
|
@@ -90,13 +87,8 @@ module Axn
|
|
|
90
87
|
end
|
|
91
88
|
end
|
|
92
89
|
end
|
|
93
|
-
ensure
|
|
94
|
-
_emit_metrics
|
|
95
90
|
end
|
|
96
91
|
|
|
97
|
-
# User-defined action logic - override this method in your action classes
|
|
98
|
-
def call; end
|
|
99
|
-
|
|
100
92
|
def fail!(message = nil, **exposures)
|
|
101
93
|
expose(**exposures) if exposures.any?
|
|
102
94
|
raise Axn::Failure, message
|
|
@@ -109,15 +101,8 @@ module Axn
|
|
|
109
101
|
|
|
110
102
|
private
|
|
111
103
|
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
Axn.config.emit_metrics.call(
|
|
116
|
-
self.class.name || "AnonymousClass",
|
|
117
|
-
result,
|
|
118
|
-
)
|
|
119
|
-
rescue StandardError => e
|
|
120
|
-
Axn::Internal::Logging.piping_error("running metrics hook", action: self, exception: e)
|
|
104
|
+
def initialize(**)
|
|
105
|
+
@__context = Axn::Context.new(**)
|
|
121
106
|
end
|
|
122
107
|
end
|
|
123
108
|
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
|
|
4
|
+
module Axn
|
|
5
|
+
module Extras
|
|
6
|
+
module Strategies
|
|
7
|
+
module Client
|
|
8
|
+
def self.configure(name: :client, prepend_config: nil, debug: false, user_agent: nil, error_handler: nil, **options, &block)
|
|
9
|
+
# Aliasing to avoid shadowing/any confusion
|
|
10
|
+
client_name = name
|
|
11
|
+
error_handler_config = error_handler
|
|
12
|
+
|
|
13
|
+
Module.new do
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
included do
|
|
17
|
+
raise ArgumentError, "client strategy: desired client name '#{client_name}' is already taken" if method_defined?(client_name)
|
|
18
|
+
|
|
19
|
+
define_method client_name do
|
|
20
|
+
# Hydrate options that are callable (e.g. procs), so we can set e.g. per-request expiration
|
|
21
|
+
# headers and/or other non-static values.
|
|
22
|
+
hydrated_options = options.transform_values do |value|
|
|
23
|
+
value.respond_to?(:call) ? value.call : value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
::Faraday.new(**hydrated_options) do |conn|
|
|
27
|
+
conn.headers["Content-Type"] = "application/json"
|
|
28
|
+
conn.headers["User-Agent"] = user_agent || "#{client_name} / Axn Client Strategy / v#{Axn::VERSION}"
|
|
29
|
+
|
|
30
|
+
# Because middleware is executed in reverse order, downstream user may need flexibility in where to inject configs
|
|
31
|
+
prepend_config&.call(conn)
|
|
32
|
+
|
|
33
|
+
conn.response :raise_error
|
|
34
|
+
conn.request :url_encoded
|
|
35
|
+
conn.request :json
|
|
36
|
+
conn.response :json, content_type: /\bjson$/
|
|
37
|
+
|
|
38
|
+
# Enable for debugging
|
|
39
|
+
conn.response :logger if debug
|
|
40
|
+
|
|
41
|
+
# Inject error handler middleware if configured
|
|
42
|
+
if error_handler_config && defined?(Faraday)
|
|
43
|
+
unless Client.const_defined?(:ErrorHandlerMiddleware, false)
|
|
44
|
+
Client.const_set(:ErrorHandlerMiddleware, Class.new(::Faraday::Middleware) do
|
|
45
|
+
def initialize(app, config)
|
|
46
|
+
super(app)
|
|
47
|
+
@config = config
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def call(env)
|
|
51
|
+
@app.call(env).on_complete do |response_env|
|
|
52
|
+
body = parse_body(response_env.body)
|
|
53
|
+
condition = @config[:if] || -> { status != 200 }
|
|
54
|
+
|
|
55
|
+
@response_env = response_env
|
|
56
|
+
@body = body
|
|
57
|
+
should_handle = instance_exec(&condition)
|
|
58
|
+
|
|
59
|
+
handle_error(response_env, body) if should_handle
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def status
|
|
64
|
+
@response_env&.status
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
attr_reader :body, :response_env
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def parse_body(body)
|
|
72
|
+
return {} if body.blank?
|
|
73
|
+
|
|
74
|
+
body.is_a?(String) ? JSON.parse(body) : body
|
|
75
|
+
rescue JSON::ParserError
|
|
76
|
+
{}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def handle_error(response_env, body)
|
|
80
|
+
error = extract_value(body, @config[:error_key])
|
|
81
|
+
details = extract_value(body, @config[:detail_key]) if @config[:detail_key]
|
|
82
|
+
backtrace = extract_value(body, @config[:backtrace_key]) if @config[:backtrace_key]
|
|
83
|
+
|
|
84
|
+
formatted_message = if @config[:formatter]
|
|
85
|
+
@config[:formatter].call(error, details, response_env)
|
|
86
|
+
else
|
|
87
|
+
format_default_message(error, details)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
prefix = "Error while #{response_env.method.to_s.upcase}ing #{response_env.url}"
|
|
91
|
+
message = formatted_message.present? ? "#{prefix}: #{formatted_message}" : prefix
|
|
92
|
+
|
|
93
|
+
exception_class = @config[:exception_class] || ::Faraday::BadRequestError
|
|
94
|
+
exception = exception_class.new(message)
|
|
95
|
+
exception.set_backtrace(backtrace) if backtrace.present?
|
|
96
|
+
raise exception
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def extract_value(data, key)
|
|
100
|
+
return nil if key.blank?
|
|
101
|
+
|
|
102
|
+
keys = key.split(".")
|
|
103
|
+
keys.reduce(data) do |current, k|
|
|
104
|
+
return nil unless current.is_a?(Hash)
|
|
105
|
+
|
|
106
|
+
current[k.to_s] || current[k.to_sym]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def format_default_message(error, details)
|
|
111
|
+
parts = []
|
|
112
|
+
parts << error if error
|
|
113
|
+
|
|
114
|
+
if details
|
|
115
|
+
if @config[:extract_detail]
|
|
116
|
+
extracted = if details.is_a?(Hash)
|
|
117
|
+
details.map { |key, value| @config[:extract_detail].call(key, value) }.compact.to_sentence
|
|
118
|
+
else
|
|
119
|
+
Array(details).map { |node| @config[:extract_detail].call(node) }.compact.to_sentence
|
|
120
|
+
end
|
|
121
|
+
parts << extracted if extracted.present?
|
|
122
|
+
elsif details.present?
|
|
123
|
+
raise ArgumentError, "must provide extract_detail when detail_key is set and details is not a string" unless details.is_a?(String)
|
|
124
|
+
|
|
125
|
+
parts << details
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
parts.compact.join(" - ")
|
|
130
|
+
end
|
|
131
|
+
end)
|
|
132
|
+
end
|
|
133
|
+
conn.use Client::ErrorHandlerMiddleware, error_handler_config
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
block&.call(conn)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
memo client_name
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
|
|
148
|
+
|
|
149
|
+
# Register the strategy only if faraday is available
|
|
150
|
+
Axn::Strategies.register(:client, Axn::Extras::Strategies::Client) if defined?(Faraday)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "axn/core/flow/handlers/invoker"
|
|
5
|
+
|
|
6
|
+
module Axn
|
|
7
|
+
module Extras
|
|
8
|
+
module Strategies
|
|
9
|
+
module Vernier
|
|
10
|
+
# @param if [Proc, Symbol, #call, nil] Optional condition to determine when to profile
|
|
11
|
+
# @param sample_rate [Float] Sampling rate (0.0 to 1.0, default: 0.1)
|
|
12
|
+
# @param output_dir [String, Pathname] Output directory for profile files (default: Rails.root/tmp/profiles or tmp/profiles)
|
|
13
|
+
# @return [Module] A configured module that adds profiling to the action
|
|
14
|
+
def self.configure(if: nil, sample_rate: 0.1, output_dir: nil)
|
|
15
|
+
condition = binding.local_variable_get(:if)
|
|
16
|
+
sample_rate_value = sample_rate
|
|
17
|
+
output_dir_value = output_dir || _default_output_dir
|
|
18
|
+
|
|
19
|
+
Module.new do
|
|
20
|
+
extend ActiveSupport::Concern
|
|
21
|
+
|
|
22
|
+
included do
|
|
23
|
+
class_attribute :_vernier_condition, default: condition
|
|
24
|
+
class_attribute :_vernier_sample_rate, default: sample_rate_value
|
|
25
|
+
class_attribute :_vernier_output_dir, default: output_dir_value
|
|
26
|
+
|
|
27
|
+
around do |hooked|
|
|
28
|
+
_with_vernier_profiling { hooked.call }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def _with_vernier_profiling(&)
|
|
35
|
+
return yield unless _should_profile?
|
|
36
|
+
|
|
37
|
+
_profile_with_vernier(&)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def _profile_with_vernier(&)
|
|
41
|
+
_ensure_vernier_available!
|
|
42
|
+
|
|
43
|
+
class_name = self.class.name.presence || "AnonymousAction"
|
|
44
|
+
profile_name = "axn_#{class_name}_#{Time.now.to_i}"
|
|
45
|
+
|
|
46
|
+
# Ensure output directory exists (only once per instance)
|
|
47
|
+
_ensure_output_directory_exists
|
|
48
|
+
|
|
49
|
+
# Build output file path
|
|
50
|
+
output_dir = self.class._vernier_output_dir || _default_output_dir
|
|
51
|
+
output_file = File.join(output_dir, "#{profile_name}.json")
|
|
52
|
+
|
|
53
|
+
# Configure Vernier with our settings
|
|
54
|
+
collector_options = {
|
|
55
|
+
out: output_file,
|
|
56
|
+
allocation_sample_rate: (self.class._vernier_sample_rate * 1000).to_i,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
::Vernier.profile(**collector_options, &)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def _ensure_output_directory_exists
|
|
63
|
+
return if @_vernier_directory_created
|
|
64
|
+
|
|
65
|
+
output_dir = self.class._vernier_output_dir || _default_output_dir
|
|
66
|
+
FileUtils.mkdir_p(output_dir)
|
|
67
|
+
@_vernier_directory_created = true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def _should_profile?
|
|
71
|
+
# Fast path: no condition means always profile
|
|
72
|
+
return true unless self.class._vernier_condition
|
|
73
|
+
|
|
74
|
+
# Slow path: evaluate condition (only when needed)
|
|
75
|
+
Axn::Core::Flow::Handlers::Invoker.call(
|
|
76
|
+
action: self,
|
|
77
|
+
handler: self.class._vernier_condition,
|
|
78
|
+
operation: "determining if profiling should run",
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def _ensure_vernier_available!
|
|
83
|
+
return if defined?(::Vernier) && ::Vernier.is_a?(Module)
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
require "vernier"
|
|
87
|
+
rescue LoadError
|
|
88
|
+
raise LoadError, <<~ERROR
|
|
89
|
+
Vernier profiler is not available. To use profiling, add 'vernier' to your Gemfile:
|
|
90
|
+
|
|
91
|
+
gem 'vernier', '~> 1.0'
|
|
92
|
+
|
|
93
|
+
Then run: bundle install
|
|
94
|
+
ERROR
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def _default_output_dir
|
|
99
|
+
if defined?(Rails) && Rails.respond_to?(:root)
|
|
100
|
+
Rails.root.join("tmp", "profiles")
|
|
101
|
+
else
|
|
102
|
+
Pathname.new("tmp/profiles")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private_class_method def self._default_output_dir
|
|
109
|
+
if defined?(Rails) && Rails.respond_to?(:root)
|
|
110
|
+
Rails.root.join("tmp", "profiles")
|
|
111
|
+
else
|
|
112
|
+
Pathname.new("tmp/profiles")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Register the strategy (it handles missing vernier dependency gracefully)
|
|
121
|
+
Axn::Strategies.register(:vernier, Axn::Extras::Strategies::Vernier)
|
data/lib/axn/extras.rb
ADDED
data/lib/axn/factory.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Axn
|
|
4
4
|
class Factory
|
|
5
|
+
NOT_PROVIDED = :__not_provided__
|
|
6
|
+
|
|
5
7
|
class << self
|
|
6
8
|
# rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/ParameterLists
|
|
7
9
|
def build(
|
|
@@ -38,6 +40,14 @@ module Axn
|
|
|
38
40
|
# Async configuration
|
|
39
41
|
async: nil,
|
|
40
42
|
|
|
43
|
+
# Logging configuration
|
|
44
|
+
log_calls: NOT_PROVIDED,
|
|
45
|
+
log_errors: NOT_PROVIDED,
|
|
46
|
+
|
|
47
|
+
# Internal flag to prevent recursion during action class creation
|
|
48
|
+
# Tracks which target class is having an action class created for it
|
|
49
|
+
_creating_action_class_for: nil,
|
|
50
|
+
|
|
41
51
|
&block
|
|
42
52
|
)
|
|
43
53
|
raise ArgumentError, "[Axn::Factory] Cannot receive both a callable and a block" if callable.present? && block_given?
|
|
@@ -66,7 +76,7 @@ module Axn
|
|
|
66
76
|
end
|
|
67
77
|
|
|
68
78
|
# NOTE: inheriting from wrapping class, so we can set default values (e.g. for HTTP headers)
|
|
69
|
-
_build_axn_class(superclass:, args:, executable:, expose_return_as:, include:, extend:, prepend:).tap do |axn|
|
|
79
|
+
_build_axn_class(superclass:, args:, executable:, expose_return_as:, include:, extend:, prepend:, _creating_action_class_for:).tap do |axn|
|
|
70
80
|
expects.each do |field, opts|
|
|
71
81
|
axn.expects(field, **opts)
|
|
72
82
|
end
|
|
@@ -75,6 +85,10 @@ module Axn
|
|
|
75
85
|
axn.exposes(field, **opts)
|
|
76
86
|
end
|
|
77
87
|
|
|
88
|
+
# Apply logging configuration (always apply if provided to override defaults)
|
|
89
|
+
axn.log_calls(log_calls) unless log_calls == NOT_PROVIDED
|
|
90
|
+
axn.log_errors(log_errors) unless log_errors == NOT_PROVIDED
|
|
91
|
+
|
|
78
92
|
# Apply success and error handlers
|
|
79
93
|
_apply_handlers(axn, :success, success, Axn::Core::Flow::Handlers::Descriptors::MessageDescriptor)
|
|
80
94
|
_apply_handlers(axn, :error, error, Axn::Core::Flow::Handlers::Descriptors::MessageDescriptor)
|
|
@@ -155,7 +169,11 @@ module Axn
|
|
|
155
169
|
end
|
|
156
170
|
end
|
|
157
171
|
|
|
158
|
-
def _build_axn_class(superclass:, args:, executable:, expose_return_as:, include: nil, extend: nil, prepend: nil)
|
|
172
|
+
def _build_axn_class(superclass:, args:, executable:, expose_return_as:, include: nil, extend: nil, prepend: nil, _creating_action_class_for: nil) # rubocop:disable Lint/UnderscorePrefixedVariableName
|
|
173
|
+
# Mark superclass if we're creating an action class (for recursion prevention)
|
|
174
|
+
# Track which target class is having an action created for it
|
|
175
|
+
superclass.instance_variable_set(:@_axn_creating_action_class_for, _creating_action_class_for) if _creating_action_class_for && superclass
|
|
176
|
+
|
|
159
177
|
Class.new(superclass || Object) do
|
|
160
178
|
include Axn unless self < Axn
|
|
161
179
|
|
|
@@ -177,6 +195,8 @@ module Axn
|
|
|
177
195
|
expose(expose_return_as => retval) if expose_return_as.present?
|
|
178
196
|
end
|
|
179
197
|
end
|
|
198
|
+
ensure
|
|
199
|
+
superclass.instance_variable_set(:@_axn_creating_action_class_for, nil) if _creating_action_class_for && superclass
|
|
180
200
|
end
|
|
181
201
|
|
|
182
202
|
def _apply_async_config(axn, async)
|