a2a-ruby 1.0.0
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +137 -0
- data/.simplecov +46 -0
- data/.yardopts +10 -0
- data/CHANGELOG.md +33 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/CONTRIBUTING.md +165 -0
- data/Gemfile +43 -0
- data/Guardfile +34 -0
- data/LICENSE.txt +21 -0
- data/PUBLISHING_CHECKLIST.md +214 -0
- data/README.md +171 -0
- data/Rakefile +165 -0
- data/docs/agent_execution.md +309 -0
- data/docs/api_reference.md +792 -0
- data/docs/configuration.md +780 -0
- data/docs/events.md +475 -0
- data/docs/getting_started.md +668 -0
- data/docs/integration.md +262 -0
- data/docs/server_apps.md +621 -0
- data/docs/troubleshooting.md +765 -0
- data/lib/a2a/client/api_methods.rb +263 -0
- data/lib/a2a/client/auth/api_key.rb +161 -0
- data/lib/a2a/client/auth/interceptor.rb +288 -0
- data/lib/a2a/client/auth/jwt.rb +189 -0
- data/lib/a2a/client/auth/oauth2.rb +146 -0
- data/lib/a2a/client/auth.rb +137 -0
- data/lib/a2a/client/base.rb +316 -0
- data/lib/a2a/client/config.rb +210 -0
- data/lib/a2a/client/connection_pool.rb +233 -0
- data/lib/a2a/client/http_client.rb +524 -0
- data/lib/a2a/client/json_rpc_handler.rb +136 -0
- data/lib/a2a/client/middleware/circuit_breaker_interceptor.rb +245 -0
- data/lib/a2a/client/middleware/logging_interceptor.rb +371 -0
- data/lib/a2a/client/middleware/rate_limit_interceptor.rb +142 -0
- data/lib/a2a/client/middleware/retry_interceptor.rb +161 -0
- data/lib/a2a/client/middleware.rb +116 -0
- data/lib/a2a/client/performance_tracker.rb +60 -0
- data/lib/a2a/configuration/defaults.rb +34 -0
- data/lib/a2a/configuration/environment_loader.rb +76 -0
- data/lib/a2a/configuration/file_loader.rb +115 -0
- data/lib/a2a/configuration/inheritance.rb +101 -0
- data/lib/a2a/configuration/validator.rb +180 -0
- data/lib/a2a/configuration.rb +201 -0
- data/lib/a2a/errors.rb +291 -0
- data/lib/a2a/modules.rb +50 -0
- data/lib/a2a/monitoring/alerting.rb +490 -0
- data/lib/a2a/monitoring/distributed_tracing.rb +398 -0
- data/lib/a2a/monitoring/health_endpoints.rb +204 -0
- data/lib/a2a/monitoring/metrics_collector.rb +438 -0
- data/lib/a2a/monitoring.rb +463 -0
- data/lib/a2a/plugin.rb +358 -0
- data/lib/a2a/plugin_manager.rb +159 -0
- data/lib/a2a/plugins/example_auth.rb +81 -0
- data/lib/a2a/plugins/example_middleware.rb +118 -0
- data/lib/a2a/plugins/example_transport.rb +76 -0
- data/lib/a2a/protocol/agent_card.rb +8 -0
- data/lib/a2a/protocol/agent_card_server.rb +584 -0
- data/lib/a2a/protocol/capability.rb +496 -0
- data/lib/a2a/protocol/json_rpc.rb +254 -0
- data/lib/a2a/protocol/message.rb +8 -0
- data/lib/a2a/protocol/task.rb +8 -0
- data/lib/a2a/rails/a2a_controller.rb +258 -0
- data/lib/a2a/rails/controller_helpers.rb +499 -0
- data/lib/a2a/rails/engine.rb +167 -0
- data/lib/a2a/rails/generators/agent_generator.rb +311 -0
- data/lib/a2a/rails/generators/install_generator.rb +209 -0
- data/lib/a2a/rails/generators/migration_generator.rb +232 -0
- data/lib/a2a/rails/generators/templates/add_a2a_indexes.rb +57 -0
- data/lib/a2a/rails/generators/templates/agent_controller.rb +122 -0
- data/lib/a2a/rails/generators/templates/agent_controller_spec.rb +160 -0
- data/lib/a2a/rails/generators/templates/agent_readme.md +200 -0
- data/lib/a2a/rails/generators/templates/create_a2a_push_notification_configs.rb +68 -0
- data/lib/a2a/rails/generators/templates/create_a2a_tasks.rb +83 -0
- data/lib/a2a/rails/generators/templates/example_agent_controller.rb +228 -0
- data/lib/a2a/rails/generators/templates/initializer.rb +108 -0
- data/lib/a2a/rails/generators/templates/push_notification_config_model.rb +228 -0
- data/lib/a2a/rails/generators/templates/task_model.rb +200 -0
- data/lib/a2a/rails/tasks/a2a.rake +228 -0
- data/lib/a2a/server/a2a_methods.rb +520 -0
- data/lib/a2a/server/agent.rb +537 -0
- data/lib/a2a/server/agent_execution/agent_executor.rb +279 -0
- data/lib/a2a/server/agent_execution/request_context.rb +219 -0
- data/lib/a2a/server/apps/rack_app.rb +311 -0
- data/lib/a2a/server/apps/sinatra_app.rb +261 -0
- data/lib/a2a/server/default_request_handler.rb +350 -0
- data/lib/a2a/server/events/event_consumer.rb +116 -0
- data/lib/a2a/server/events/event_queue.rb +226 -0
- data/lib/a2a/server/example_agent.rb +248 -0
- data/lib/a2a/server/handler.rb +281 -0
- data/lib/a2a/server/middleware/authentication_middleware.rb +212 -0
- data/lib/a2a/server/middleware/cors_middleware.rb +171 -0
- data/lib/a2a/server/middleware/logging_middleware.rb +362 -0
- data/lib/a2a/server/middleware/rate_limit_middleware.rb +382 -0
- data/lib/a2a/server/middleware.rb +213 -0
- data/lib/a2a/server/push_notification_manager.rb +327 -0
- data/lib/a2a/server/request_handler.rb +136 -0
- data/lib/a2a/server/storage/base.rb +141 -0
- data/lib/a2a/server/storage/database.rb +266 -0
- data/lib/a2a/server/storage/memory.rb +274 -0
- data/lib/a2a/server/storage/redis.rb +320 -0
- data/lib/a2a/server/storage.rb +38 -0
- data/lib/a2a/server/task_manager.rb +534 -0
- data/lib/a2a/transport/grpc.rb +481 -0
- data/lib/a2a/transport/http.rb +415 -0
- data/lib/a2a/transport/sse.rb +499 -0
- data/lib/a2a/types/agent_card.rb +540 -0
- data/lib/a2a/types/artifact.rb +99 -0
- data/lib/a2a/types/base_model.rb +223 -0
- data/lib/a2a/types/events.rb +117 -0
- data/lib/a2a/types/message.rb +106 -0
- data/lib/a2a/types/part.rb +288 -0
- data/lib/a2a/types/push_notification.rb +139 -0
- data/lib/a2a/types/security.rb +167 -0
- data/lib/a2a/types/task.rb +154 -0
- data/lib/a2a/types.rb +88 -0
- data/lib/a2a/utils/helpers.rb +245 -0
- data/lib/a2a/utils/message_buffer.rb +278 -0
- data/lib/a2a/utils/performance.rb +247 -0
- data/lib/a2a/utils/rails_detection.rb +97 -0
- data/lib/a2a/utils/structured_logger.rb +306 -0
- data/lib/a2a/utils/time_helpers.rb +167 -0
- data/lib/a2a/utils/validation.rb +8 -0
- data/lib/a2a/version.rb +6 -0
- data/lib/a2a-rails.rb +58 -0
- data/lib/a2a.rb +198 -0
- metadata +437 -0
@@ -0,0 +1,245 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
# Circuit breaker interceptor for fault tolerance
|
5
|
+
#
|
6
|
+
# Implements the circuit breaker pattern to prevent cascading failures
|
7
|
+
# by temporarily stopping requests to a failing service.
|
8
|
+
#
|
9
|
+
module A2A
|
10
|
+
module Client
|
11
|
+
module Middleware
|
12
|
+
class CircuitBreakerInterceptor
|
13
|
+
# Circuit breaker states
|
14
|
+
CLOSED = :closed
|
15
|
+
OPEN = :open
|
16
|
+
HALF_OPEN = :half_open
|
17
|
+
|
18
|
+
attr_reader :failure_threshold, :timeout, :expected_errors, :state,
|
19
|
+
:failure_count, :last_failure_time, :success_count
|
20
|
+
|
21
|
+
##
|
22
|
+
# Initialize circuit breaker interceptor
|
23
|
+
#
|
24
|
+
# @param failure_threshold [Integer] Number of failures before opening circuit (default: 5)
|
25
|
+
# @param timeout [Integer] Timeout in seconds before trying half-open (default: 60)
|
26
|
+
# @param expected_errors [Array<Class>] Error classes that should trigger circuit breaker
|
27
|
+
def initialize(failure_threshold: 5, timeout: 60, expected_errors: nil)
|
28
|
+
@failure_threshold = failure_threshold
|
29
|
+
@timeout = timeout
|
30
|
+
@expected_errors = expected_errors || default_expected_errors
|
31
|
+
@state = CLOSED
|
32
|
+
@failure_count = 0
|
33
|
+
@success_count = 0
|
34
|
+
@last_failure_time = nil
|
35
|
+
@mutex = Mutex.new
|
36
|
+
|
37
|
+
validate_configuration!
|
38
|
+
end
|
39
|
+
|
40
|
+
##
|
41
|
+
# Execute request with circuit breaker logic
|
42
|
+
#
|
43
|
+
# @param request [Object] The request object
|
44
|
+
# @param context [Hash] Request context
|
45
|
+
# @param next_middleware [Proc] Next middleware in chain
|
46
|
+
# @return [Object] Response from next middleware
|
47
|
+
def call(request, context, next_middleware)
|
48
|
+
@mutex.synchronize do
|
49
|
+
case @state
|
50
|
+
when CLOSED
|
51
|
+
execute_request(request, context, next_middleware)
|
52
|
+
when OPEN
|
53
|
+
check_timeout_and_execute(request, context, next_middleware)
|
54
|
+
when HALF_OPEN
|
55
|
+
execute_half_open_request(request, context, next_middleware)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
##
|
61
|
+
# Get current circuit breaker status
|
62
|
+
#
|
63
|
+
# @return [Hash] Circuit breaker status information
|
64
|
+
def status
|
65
|
+
@mutex.synchronize do
|
66
|
+
{
|
67
|
+
state: @state,
|
68
|
+
failure_count: @failure_count,
|
69
|
+
success_count: @success_count,
|
70
|
+
failure_threshold: @failure_threshold,
|
71
|
+
timeout: @timeout,
|
72
|
+
last_failure_time: @last_failure_time,
|
73
|
+
time_until_half_open: time_until_half_open
|
74
|
+
}
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
##
|
79
|
+
# Check if the circuit breaker is open
|
80
|
+
#
|
81
|
+
# @return [Boolean] True if circuit is open
|
82
|
+
def open?
|
83
|
+
@state == OPEN
|
84
|
+
end
|
85
|
+
|
86
|
+
##
|
87
|
+
# Check if the circuit breaker is closed
|
88
|
+
#
|
89
|
+
# @return [Boolean] True if circuit is closed
|
90
|
+
def closed?
|
91
|
+
@state == CLOSED
|
92
|
+
end
|
93
|
+
|
94
|
+
##
|
95
|
+
# Check if the circuit breaker is half-open
|
96
|
+
#
|
97
|
+
# @return [Boolean] True if circuit is half-open
|
98
|
+
def half_open?
|
99
|
+
@state == HALF_OPEN
|
100
|
+
end
|
101
|
+
|
102
|
+
##
|
103
|
+
# Reset the circuit breaker to closed state
|
104
|
+
def reset!
|
105
|
+
@mutex.synchronize do
|
106
|
+
@state = CLOSED
|
107
|
+
@failure_count = 0
|
108
|
+
@success_count = 0
|
109
|
+
@last_failure_time = nil
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
##
|
114
|
+
# Force the circuit breaker to open state
|
115
|
+
def trip!
|
116
|
+
@mutex.synchronize do
|
117
|
+
@state = OPEN
|
118
|
+
@last_failure_time = Time.now
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
private
|
123
|
+
|
124
|
+
##
|
125
|
+
# Execute request in closed state
|
126
|
+
def execute_request(request, context, next_middleware)
|
127
|
+
response = next_middleware.call(request, context)
|
128
|
+
on_success
|
129
|
+
response
|
130
|
+
rescue StandardError => e
|
131
|
+
on_failure if circuit_breaker_error?(e)
|
132
|
+
raise e
|
133
|
+
end
|
134
|
+
|
135
|
+
##
|
136
|
+
# Check timeout and execute request when circuit is open
|
137
|
+
def check_timeout_and_execute(request, context, next_middleware)
|
138
|
+
raise A2A::Errors::AgentUnavailable, "Circuit breaker is OPEN. Service unavailable." unless timeout_expired?
|
139
|
+
|
140
|
+
@state = HALF_OPEN
|
141
|
+
@success_count = 0
|
142
|
+
execute_half_open_request(request, context, next_middleware)
|
143
|
+
end
|
144
|
+
|
145
|
+
##
|
146
|
+
# Execute request in half-open state
|
147
|
+
def execute_half_open_request(request, context, next_middleware)
|
148
|
+
response = next_middleware.call(request, context)
|
149
|
+
on_half_open_success
|
150
|
+
response
|
151
|
+
rescue StandardError => e
|
152
|
+
on_half_open_failure if circuit_breaker_error?(e)
|
153
|
+
raise e
|
154
|
+
end
|
155
|
+
|
156
|
+
##
|
157
|
+
# Handle successful request
|
158
|
+
def on_success
|
159
|
+
@failure_count = 0
|
160
|
+
end
|
161
|
+
|
162
|
+
##
|
163
|
+
# Handle failed request
|
164
|
+
def on_failure
|
165
|
+
@failure_count += 1
|
166
|
+
@last_failure_time = Time.now
|
167
|
+
|
168
|
+
return unless @failure_count >= @failure_threshold
|
169
|
+
|
170
|
+
@state = OPEN
|
171
|
+
end
|
172
|
+
|
173
|
+
##
|
174
|
+
# Handle successful request in half-open state
|
175
|
+
def on_half_open_success
|
176
|
+
@success_count += 1
|
177
|
+
|
178
|
+
# Close circuit after successful request in half-open state
|
179
|
+
@state = CLOSED
|
180
|
+
@failure_count = 0
|
181
|
+
@success_count = 0
|
182
|
+
end
|
183
|
+
|
184
|
+
##
|
185
|
+
# Handle failed request in half-open state
|
186
|
+
def on_half_open_failure
|
187
|
+
@state = OPEN
|
188
|
+
@failure_count += 1
|
189
|
+
@last_failure_time = Time.now
|
190
|
+
end
|
191
|
+
|
192
|
+
##
|
193
|
+
# Check if timeout has expired for opening circuit
|
194
|
+
def timeout_expired?
|
195
|
+
return false unless @last_failure_time
|
196
|
+
|
197
|
+
Time.now - @last_failure_time >= @timeout
|
198
|
+
end
|
199
|
+
|
200
|
+
##
|
201
|
+
# Calculate time until circuit can be half-open
|
202
|
+
def time_until_half_open
|
203
|
+
return 0 unless @state == OPEN && @last_failure_time
|
204
|
+
|
205
|
+
elapsed = Time.now - @last_failure_time
|
206
|
+
remaining = @timeout - elapsed
|
207
|
+
[remaining, 0].max
|
208
|
+
end
|
209
|
+
|
210
|
+
##
|
211
|
+
# Check if error should trigger circuit breaker
|
212
|
+
def circuit_breaker_error?(error)
|
213
|
+
@expected_errors.any? { |error_class| error.is_a?(error_class) }
|
214
|
+
end
|
215
|
+
|
216
|
+
##
|
217
|
+
# Default error classes that should trigger circuit breaker
|
218
|
+
def default_expected_errors
|
219
|
+
[
|
220
|
+
A2A::Errors::TimeoutError,
|
221
|
+
A2A::Errors::HTTPError,
|
222
|
+
A2A::Errors::TransportError,
|
223
|
+
A2A::Errors::AgentUnavailable,
|
224
|
+
A2A::Errors::ResourceExhausted,
|
225
|
+
A2A::Errors::InternalError,
|
226
|
+
Faraday::TimeoutError,
|
227
|
+
Faraday::ConnectionFailed
|
228
|
+
]
|
229
|
+
end
|
230
|
+
|
231
|
+
##
|
232
|
+
# Validate configuration parameters
|
233
|
+
def validate_configuration!
|
234
|
+
raise ArgumentError, "failure_threshold must be positive" if @failure_threshold <= 0
|
235
|
+
|
236
|
+
raise ArgumentError, "timeout must be positive" if @timeout <= 0
|
237
|
+
|
238
|
+
return if @expected_errors.is_a?(Array)
|
239
|
+
|
240
|
+
raise ArgumentError, "expected_errors must be an array"
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
245
|
+
end
|
@@ -0,0 +1,371 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "logger"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
##
|
7
|
+
# Logging interceptor for request/response debugging
|
8
|
+
#
|
9
|
+
# Provides comprehensive logging of A2A requests, responses, and errors
|
10
|
+
# with configurable log levels and filtering.
|
11
|
+
#
|
12
|
+
module A2A
|
13
|
+
module Client
|
14
|
+
module Middleware
|
15
|
+
class LoggingInterceptor
|
16
|
+
attr_reader :logger, :log_level, :log_requests, :log_responses, :log_errors
|
17
|
+
|
18
|
+
##
|
19
|
+
# Initialize logging interceptor
|
20
|
+
#
|
21
|
+
# @param logger [Logger, nil] Logger instance (creates default if nil)
|
22
|
+
# @param log_level [Symbol] Log level (:debug, :info, :warn, :error)
|
23
|
+
# @param log_requests [Boolean] Whether to log requests (default: true)
|
24
|
+
# @param log_responses [Boolean] Whether to log responses (default: true)
|
25
|
+
# @param log_errors [Boolean] Whether to log errors (default: true)
|
26
|
+
# @param mask_sensitive [Boolean] Whether to mask sensitive data (default: true)
|
27
|
+
def initialize(logger: nil, log_level: :info, log_requests: true,
|
28
|
+
log_responses: true, log_errors: true, mask_sensitive: true)
|
29
|
+
@logger = logger || create_default_logger
|
30
|
+
@log_level = log_level
|
31
|
+
@log_requests = log_requests
|
32
|
+
@log_responses = log_responses
|
33
|
+
@log_errors = log_errors
|
34
|
+
@mask_sensitive = mask_sensitive
|
35
|
+
|
36
|
+
validate_configuration!
|
37
|
+
end
|
38
|
+
|
39
|
+
##
|
40
|
+
# Execute request with logging
|
41
|
+
#
|
42
|
+
# @param request [Object] The request object
|
43
|
+
# @param context [Hash] Request context
|
44
|
+
# @param next_middleware [Proc] Next middleware in chain
|
45
|
+
# @return [Object] Response from next middleware
|
46
|
+
def call(request, context, next_middleware)
|
47
|
+
request_id = context[:request_id] || generate_request_id
|
48
|
+
context[:request_id] = request_id
|
49
|
+
|
50
|
+
start_time = Time.now
|
51
|
+
|
52
|
+
log_request(request, context) if @log_requests
|
53
|
+
|
54
|
+
begin
|
55
|
+
response = next_middleware.call(request, context)
|
56
|
+
|
57
|
+
duration = Time.now - start_time
|
58
|
+
log_response(response, context, duration) if @log_responses
|
59
|
+
|
60
|
+
response
|
61
|
+
rescue StandardError => e
|
62
|
+
duration = Time.now - start_time
|
63
|
+
log_error(e, context, duration) if @log_errors
|
64
|
+
raise e
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
##
|
69
|
+
# Log a request
|
70
|
+
#
|
71
|
+
# @param request [Object] The request object
|
72
|
+
# @param context [Hash] Request context
|
73
|
+
def log_request(request, context)
|
74
|
+
log_data = {
|
75
|
+
type: "request",
|
76
|
+
request_id: context[:request_id],
|
77
|
+
timestamp: Time.now.utc.iso8601,
|
78
|
+
method: extract_method(request),
|
79
|
+
url: extract_url(request),
|
80
|
+
headers: mask_headers(extract_headers(request)),
|
81
|
+
body: mask_body(extract_body(request)),
|
82
|
+
context: sanitize_context(context)
|
83
|
+
}
|
84
|
+
|
85
|
+
@logger.send(@log_level, format_log_message("A2A Request", log_data))
|
86
|
+
end
|
87
|
+
|
88
|
+
##
|
89
|
+
# Log a response
|
90
|
+
#
|
91
|
+
# @param response [Object] The response object
|
92
|
+
# @param context [Hash] Request context
|
93
|
+
# @param duration [Float] Request duration in seconds
|
94
|
+
def log_response(response, context, duration)
|
95
|
+
log_data = {
|
96
|
+
type: "response",
|
97
|
+
request_id: context[:request_id],
|
98
|
+
timestamp: Time.now.utc.iso8601,
|
99
|
+
duration_ms: (duration * 1000).round(2),
|
100
|
+
status: extract_status(response),
|
101
|
+
headers: mask_headers(extract_response_headers(response)),
|
102
|
+
body: mask_body(extract_response_body(response)),
|
103
|
+
success: response_successful?(response)
|
104
|
+
}
|
105
|
+
|
106
|
+
level = response_successful?(response) ? @log_level : :warn
|
107
|
+
@logger.send(level, format_log_message("A2A Response", log_data))
|
108
|
+
end
|
109
|
+
|
110
|
+
##
|
111
|
+
# Log an error
|
112
|
+
#
|
113
|
+
# @param error [Exception] The error that occurred
|
114
|
+
# @param context [Hash] Request context
|
115
|
+
# @param duration [Float] Request duration in seconds
|
116
|
+
def log_error(error, context, duration)
|
117
|
+
log_data = {
|
118
|
+
type: "error",
|
119
|
+
request_id: context[:request_id],
|
120
|
+
timestamp: Time.now.utc.iso8601,
|
121
|
+
duration_ms: (duration * 1000).round(2),
|
122
|
+
error_class: error.class.name,
|
123
|
+
error_message: error.message,
|
124
|
+
error_code: error.respond_to?(:code) ? error.code : nil,
|
125
|
+
backtrace: error.backtrace&.first(10),
|
126
|
+
retry_attempt: context[:retry_attempt]
|
127
|
+
}
|
128
|
+
|
129
|
+
@logger.error(format_log_message("A2A Error", log_data))
|
130
|
+
end
|
131
|
+
|
132
|
+
private
|
133
|
+
|
134
|
+
##
|
135
|
+
# Create default logger
|
136
|
+
#
|
137
|
+
# @return [Logger] Default logger instance
|
138
|
+
def create_default_logger
|
139
|
+
logger = Logger.new($stdout)
|
140
|
+
logger.level = Logger::INFO
|
141
|
+
logger.formatter = proc do |severity, datetime, _progname, msg|
|
142
|
+
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}: #{msg}\n"
|
143
|
+
end
|
144
|
+
logger
|
145
|
+
end
|
146
|
+
|
147
|
+
##
|
148
|
+
# Generate a unique request ID
|
149
|
+
#
|
150
|
+
# @return [String] Request ID
|
151
|
+
def generate_request_id
|
152
|
+
require "securerandom"
|
153
|
+
SecureRandom.hex(8)
|
154
|
+
end
|
155
|
+
|
156
|
+
##
|
157
|
+
# Format log message
|
158
|
+
#
|
159
|
+
# @param title [String] Log message title
|
160
|
+
# @param data [Hash] Log data
|
161
|
+
# @return [String] Formatted log message
|
162
|
+
def format_log_message(title, data)
|
163
|
+
"#{title}: #{JSON.pretty_generate(data)}"
|
164
|
+
rescue JSON::GeneratorError
|
165
|
+
"#{title}: #{data.inspect}"
|
166
|
+
end
|
167
|
+
|
168
|
+
##
|
169
|
+
# Extract method from request
|
170
|
+
#
|
171
|
+
# @param request [Object] The request object
|
172
|
+
# @return [String, nil] HTTP method or JSON-RPC method
|
173
|
+
def extract_method(request)
|
174
|
+
if request.respond_to?(:method)
|
175
|
+
request.method
|
176
|
+
elsif request.respond_to?(:[])
|
177
|
+
request["method"] || request[:method]
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
##
|
182
|
+
# Extract URL from request
|
183
|
+
#
|
184
|
+
# @param request [Object] The request object
|
185
|
+
# @return [String, nil] Request URL
|
186
|
+
def extract_url(request)
|
187
|
+
if request.respond_to?(:url)
|
188
|
+
request.url
|
189
|
+
elsif request.respond_to?(:uri)
|
190
|
+
request.uri.to_s
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
##
|
195
|
+
# Extract headers from request
|
196
|
+
#
|
197
|
+
# @param request [Object] The request object
|
198
|
+
# @return [Hash] Request headers
|
199
|
+
def extract_headers(request)
|
200
|
+
if request.respond_to?(:headers)
|
201
|
+
request.headers.to_h
|
202
|
+
else
|
203
|
+
{}
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
##
|
208
|
+
# Extract body from request
|
209
|
+
#
|
210
|
+
# @param request [Object] The request object
|
211
|
+
# @return [String, Hash, nil] Request body
|
212
|
+
def extract_body(request)
|
213
|
+
if request.respond_to?(:body)
|
214
|
+
body = request.body
|
215
|
+
return parse_json_body(body) if body.is_a?(String)
|
216
|
+
|
217
|
+
body
|
218
|
+
elsif request.respond_to?(:to_h)
|
219
|
+
request.to_h
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
##
|
224
|
+
# Extract status from response
|
225
|
+
#
|
226
|
+
# @param response [Object] The response object
|
227
|
+
# @return [Integer, String, nil] Response status
|
228
|
+
def extract_status(response)
|
229
|
+
if response.respond_to?(:status)
|
230
|
+
response.status
|
231
|
+
elsif response.respond_to?(:[])
|
232
|
+
response["status"] || response[:status]
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
##
|
237
|
+
# Extract headers from response
|
238
|
+
#
|
239
|
+
# @param response [Object] The response object
|
240
|
+
# @return [Hash] Response headers
|
241
|
+
def extract_response_headers(response)
|
242
|
+
if response.respond_to?(:headers)
|
243
|
+
response.headers.to_h
|
244
|
+
else
|
245
|
+
{}
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
##
|
250
|
+
# Extract body from response
|
251
|
+
#
|
252
|
+
# @param response [Object] The response object
|
253
|
+
# @return [String, Hash, nil] Response body
|
254
|
+
def extract_response_body(response)
|
255
|
+
if response.respond_to?(:body)
|
256
|
+
body = response.body
|
257
|
+
return parse_json_body(body) if body.is_a?(String)
|
258
|
+
|
259
|
+
body
|
260
|
+
elsif response.respond_to?(:to_h)
|
261
|
+
response.to_h
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
##
|
266
|
+
# Check if response was successful
|
267
|
+
#
|
268
|
+
# @param response [Object] The response object
|
269
|
+
# @return [Boolean] True if successful
|
270
|
+
def response_successful?(response)
|
271
|
+
if response.respond_to?(:success?)
|
272
|
+
response.success?
|
273
|
+
elsif response.respond_to?(:status)
|
274
|
+
(200..299).cover?(response.status)
|
275
|
+
elsif response.respond_to?(:[])
|
276
|
+
!response["error"] && !response[:error]
|
277
|
+
else
|
278
|
+
true # Assume success if we can't determine
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
##
|
283
|
+
# Parse JSON body
|
284
|
+
#
|
285
|
+
# @param body [String] JSON body string
|
286
|
+
# @return [Hash, String] Parsed JSON or original string
|
287
|
+
def parse_json_body(body)
|
288
|
+
JSON.parse(body)
|
289
|
+
rescue JSON::ParserError
|
290
|
+
body
|
291
|
+
end
|
292
|
+
|
293
|
+
##
|
294
|
+
# Mask sensitive headers
|
295
|
+
#
|
296
|
+
# @param headers [Hash] Headers to mask
|
297
|
+
# @return [Hash] Masked headers
|
298
|
+
def mask_headers(headers)
|
299
|
+
return headers unless @mask_sensitive
|
300
|
+
|
301
|
+
masked = headers.dup
|
302
|
+
|
303
|
+
# Mask authorization headers
|
304
|
+
masked.each do |key, value|
|
305
|
+
next unless key.to_s.downcase.include?("authorization") ||
|
306
|
+
key.to_s.downcase.include?("token") ||
|
307
|
+
key.to_s.downcase.include?("key")
|
308
|
+
|
309
|
+
masked[key] = mask_value(value)
|
310
|
+
end
|
311
|
+
|
312
|
+
masked
|
313
|
+
end
|
314
|
+
|
315
|
+
##
|
316
|
+
# Mask sensitive body content
|
317
|
+
#
|
318
|
+
# @param body [Object] Body to mask
|
319
|
+
# @return [Object] Masked body
|
320
|
+
def mask_body(body)
|
321
|
+
return body unless @mask_sensitive
|
322
|
+
return body unless body.is_a?(Hash)
|
323
|
+
|
324
|
+
masked = body.dup
|
325
|
+
|
326
|
+
# Mask common sensitive fields
|
327
|
+
%w[password secret token key credential].each do |field|
|
328
|
+
masked.each do |k, v|
|
329
|
+
masked[k] = mask_value(v) if k.to_s.downcase.include?(field)
|
330
|
+
end
|
331
|
+
end
|
332
|
+
|
333
|
+
masked
|
334
|
+
end
|
335
|
+
|
336
|
+
##
|
337
|
+
# Mask a sensitive value
|
338
|
+
#
|
339
|
+
# @param value [String] Value to mask
|
340
|
+
# @return [String] Masked value
|
341
|
+
def mask_value(value)
|
342
|
+
return "[nil]" if value.nil?
|
343
|
+
return "[empty]" if value.to_s.empty?
|
344
|
+
|
345
|
+
str = value.to_s
|
346
|
+
return str if str.length <= 8
|
347
|
+
|
348
|
+
"#{str[0..3]}#{'*' * (str.length - 8)}#{str[-4..]}"
|
349
|
+
end
|
350
|
+
|
351
|
+
##
|
352
|
+
# Sanitize context for logging
|
353
|
+
#
|
354
|
+
# @param context [Hash] Context to sanitize
|
355
|
+
# @return [Hash] Sanitized context
|
356
|
+
def sanitize_context(context)
|
357
|
+
context.reject { |k, _v| k.to_s.include?("password") || k.to_s.include?("secret") }
|
358
|
+
end
|
359
|
+
|
360
|
+
##
|
361
|
+
# Validate configuration
|
362
|
+
def validate_configuration!
|
363
|
+
valid_levels = %i[debug info warn error]
|
364
|
+
return if valid_levels.include?(@log_level)
|
365
|
+
|
366
|
+
raise ArgumentError, "Invalid log level: #{@log_level}. Must be one of: #{valid_levels.join(', ')}"
|
367
|
+
end
|
368
|
+
end
|
369
|
+
end
|
370
|
+
end
|
371
|
+
end
|