coolhand 0.1.4 → 0.2.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 +4 -4
- data/.rubocop.yml +2 -1
- data/CHANGELOG.md +53 -0
- data/README.md +32 -33
- data/docs/anthropic.md +518 -0
- data/lib/coolhand/ruby/anthropic_interceptor.rb +300 -0
- data/lib/coolhand/ruby/api_service.rb +18 -3
- data/lib/coolhand/ruby/base_interceptor.rb +148 -0
- data/lib/coolhand/ruby/faraday_interceptor.rb +129 -0
- data/lib/coolhand/ruby/version.rb +1 -1
- data/lib/coolhand/ruby.rb +38 -7
- metadata +12 -7
- data/coolhand-ruby.gemspec +0 -42
- data/lib/coolhand/ruby/interceptor.rb +0 -118
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "anthropic"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# Anthropic gem not available - interceptor will be a no-op
|
|
7
|
+
end
|
|
8
|
+
require "securerandom"
|
|
9
|
+
require "json"
|
|
10
|
+
require "ostruct"
|
|
11
|
+
require_relative "base_interceptor"
|
|
12
|
+
|
|
13
|
+
module Coolhand
|
|
14
|
+
module Ruby
|
|
15
|
+
module AnthropicInterceptor
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def patch!
|
|
19
|
+
return if @patched
|
|
20
|
+
return unless defined?(Anthropic)
|
|
21
|
+
|
|
22
|
+
# Check if both anthropic gems are installed
|
|
23
|
+
if both_gems_installed?
|
|
24
|
+
# Always show this warning, regardless of silent mode
|
|
25
|
+
warn_message = "⚠️ Warning: Both 'anthropic' and 'ruby-anthropic' gems are installed. " \
|
|
26
|
+
"Coolhand will only monitor ruby-anthropic (Faraday-based) requests. " \
|
|
27
|
+
"Official anthropic gem monitoring has been disabled."
|
|
28
|
+
puts "COOLHAND: #{warn_message}"
|
|
29
|
+
|
|
30
|
+
# Mark as patched since ruby-anthropic will be handled by FaradayInterceptor
|
|
31
|
+
@patched = true
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Check if we're using the official anthropic gem
|
|
36
|
+
# The official gem has Anthropic::Internal, ruby-anthropic doesn't
|
|
37
|
+
if defined?(Anthropic::Internal)
|
|
38
|
+
# Patch the BaseClient request method for official anthropic gem
|
|
39
|
+
require "anthropic/internal/transport/base_client"
|
|
40
|
+
::Anthropic::Internal::Transport::BaseClient.prepend(RequestInterceptor)
|
|
41
|
+
else
|
|
42
|
+
# ruby-anthropic uses Faraday, so the FaradayInterceptor already handles it
|
|
43
|
+
Coolhand.log "✅ ruby-anthropic detected, using Faraday interceptor"
|
|
44
|
+
@patched = true
|
|
45
|
+
return
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Patch MessageStream to capture completion data
|
|
49
|
+
patch_message_stream!
|
|
50
|
+
|
|
51
|
+
@patched = true
|
|
52
|
+
Coolhand.log "✅ Anthropic interceptor patched"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def unpatch!
|
|
56
|
+
# NOTE: Ruby doesn't have a clean way to unpatch prepended modules
|
|
57
|
+
# This is mainly for testing - in production, patching is permanent
|
|
58
|
+
@patched = false
|
|
59
|
+
Coolhand.log "⚠️ Anthropic interceptor unpatch requested (not fully implemented)"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def patched?
|
|
63
|
+
@patched ||= false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def both_gems_installed?
|
|
67
|
+
# Check if both gems are installed by looking at loaded specs
|
|
68
|
+
anthropic_gem = Gem.loaded_specs["anthropic"]
|
|
69
|
+
ruby_anthropic_gem = Gem.loaded_specs["ruby-anthropic"]
|
|
70
|
+
|
|
71
|
+
anthropic_gem && ruby_anthropic_gem
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def patch_message_stream!
|
|
75
|
+
# Try to load MessageStream class if available
|
|
76
|
+
begin
|
|
77
|
+
require "anthropic/helpers/streaming/message_stream"
|
|
78
|
+
rescue LoadError
|
|
79
|
+
# MessageStream not available in this version of anthropic gem
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Only proceed if the constant is now defined
|
|
84
|
+
return unless defined?(Anthropic::Streaming::MessageStream)
|
|
85
|
+
|
|
86
|
+
# Prepend our patch module
|
|
87
|
+
::Anthropic::Streaming::MessageStream.prepend(MessageStreamInterceptor)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
module RequestInterceptor
|
|
91
|
+
def request(method:, path:, body: nil, headers: {}, **options)
|
|
92
|
+
# Generate request ID for correlation
|
|
93
|
+
request_id = SecureRandom.hex(16)
|
|
94
|
+
start_time = Time.now
|
|
95
|
+
|
|
96
|
+
# Store request ID in thread-local storage for application access
|
|
97
|
+
Thread.current[:coolhand_current_request_id] = request_id
|
|
98
|
+
|
|
99
|
+
# Temporarily disable Faraday interception for this thread to prevent double logging
|
|
100
|
+
Thread.current[:coolhand_disable_faraday] = true
|
|
101
|
+
|
|
102
|
+
# Extract request metadata
|
|
103
|
+
full_url = "#{@base_url}#{path}"
|
|
104
|
+
|
|
105
|
+
# Capture all request headers including those added by the client
|
|
106
|
+
request_headers = BaseInterceptor.clean_request_headers(headers.dup)
|
|
107
|
+
request_body = body
|
|
108
|
+
|
|
109
|
+
# Detect if this is a streaming request
|
|
110
|
+
is_streaming = streaming_request?(body, headers)
|
|
111
|
+
|
|
112
|
+
# Call the original request method
|
|
113
|
+
begin
|
|
114
|
+
response = super
|
|
115
|
+
end_time = Time.now
|
|
116
|
+
duration_ms = ((end_time - start_time) * 1000).round(2)
|
|
117
|
+
|
|
118
|
+
# For streaming responses, store request metadata for later logging
|
|
119
|
+
if is_streaming
|
|
120
|
+
Thread.current[:coolhand_streaming_request] = {
|
|
121
|
+
request_id: request_id,
|
|
122
|
+
method: method,
|
|
123
|
+
url: full_url,
|
|
124
|
+
request_headers: request_headers,
|
|
125
|
+
request_body: request_body,
|
|
126
|
+
start_time: start_time,
|
|
127
|
+
end_time: end_time,
|
|
128
|
+
duration_ms: duration_ms,
|
|
129
|
+
is_streaming: is_streaming
|
|
130
|
+
}
|
|
131
|
+
else
|
|
132
|
+
# Extract response data
|
|
133
|
+
response_data = BaseInterceptor.extract_response_data(response)
|
|
134
|
+
|
|
135
|
+
# Send complete request/response data in single API call
|
|
136
|
+
BaseInterceptor.send_complete_request_log(
|
|
137
|
+
request_id: request_id,
|
|
138
|
+
method: method,
|
|
139
|
+
url: full_url,
|
|
140
|
+
request_headers: request_headers,
|
|
141
|
+
request_body: request_body,
|
|
142
|
+
response_headers: extract_response_headers(response),
|
|
143
|
+
response_body: response_data,
|
|
144
|
+
status_code: nil,
|
|
145
|
+
start_time: start_time,
|
|
146
|
+
end_time: end_time,
|
|
147
|
+
duration_ms: duration_ms,
|
|
148
|
+
is_streaming: is_streaming
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
response
|
|
153
|
+
rescue StandardError => e
|
|
154
|
+
end_time = Time.now
|
|
155
|
+
duration_ms = ((end_time - start_time) * 1000).round(2)
|
|
156
|
+
|
|
157
|
+
# Send error response in single API call
|
|
158
|
+
BaseInterceptor.send_complete_request_log(
|
|
159
|
+
request_id: request_id,
|
|
160
|
+
method: method,
|
|
161
|
+
url: full_url,
|
|
162
|
+
request_headers: request_headers,
|
|
163
|
+
request_body: request_body,
|
|
164
|
+
response_headers: {},
|
|
165
|
+
response_body: {
|
|
166
|
+
error: {
|
|
167
|
+
message: e.message,
|
|
168
|
+
class: e.class.name
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
status_code: nil,
|
|
172
|
+
start_time: start_time,
|
|
173
|
+
end_time: end_time,
|
|
174
|
+
duration_ms: duration_ms,
|
|
175
|
+
is_streaming: is_streaming
|
|
176
|
+
)
|
|
177
|
+
raise
|
|
178
|
+
ensure
|
|
179
|
+
# Always re-enable Faraday interception for this thread
|
|
180
|
+
Thread.current[:coolhand_disable_faraday] = false
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Public method for applications to log final streaming response
|
|
185
|
+
def self.log_streaming_completion(final_response_body)
|
|
186
|
+
streaming_request = Thread.current[:coolhand_streaming_request]
|
|
187
|
+
return unless streaming_request
|
|
188
|
+
|
|
189
|
+
begin
|
|
190
|
+
send_complete_request_log(
|
|
191
|
+
request_id: streaming_request[:request_id],
|
|
192
|
+
method: streaming_request[:method],
|
|
193
|
+
url: streaming_request[:url],
|
|
194
|
+
request_headers: streaming_request[:request_headers],
|
|
195
|
+
request_body: streaming_request[:request_body],
|
|
196
|
+
response_headers: {},
|
|
197
|
+
response_body: final_response_body,
|
|
198
|
+
start_time: streaming_request[:start_time],
|
|
199
|
+
end_time: streaming_request[:end_time],
|
|
200
|
+
duration_ms: streaming_request[:duration_ms],
|
|
201
|
+
is_streaming: streaming_request[:is_streaming]
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Clear the thread-local data
|
|
205
|
+
Thread.current[:coolhand_streaming_request] = nil
|
|
206
|
+
rescue StandardError => e
|
|
207
|
+
Coolhand.log "❌ Error logging streaming completion: #{e.message}"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:,
|
|
212
|
+
response_headers:, response_body:, start_time:, end_time:, duration_ms:, is_streaming:)
|
|
213
|
+
BaseInterceptor.send_complete_request_log(
|
|
214
|
+
request_id: request_id,
|
|
215
|
+
method: method,
|
|
216
|
+
url: url,
|
|
217
|
+
request_headers: request_headers,
|
|
218
|
+
request_body: request_body,
|
|
219
|
+
response_headers: response_headers,
|
|
220
|
+
response_body: response_body,
|
|
221
|
+
status_code: nil,
|
|
222
|
+
start_time: start_time,
|
|
223
|
+
end_time: end_time,
|
|
224
|
+
duration_ms: duration_ms,
|
|
225
|
+
is_streaming: is_streaming
|
|
226
|
+
)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
def streaming_request?(body, headers)
|
|
232
|
+
# Check if stream parameter is set in request body
|
|
233
|
+
return true if body.is_a?(Hash) && body[:stream] == true
|
|
234
|
+
|
|
235
|
+
# Check Accept header for Server-Sent Events
|
|
236
|
+
accept_header = headers["Accept"] || headers["accept"]
|
|
237
|
+
return true if accept_header&.include?("text/event-stream")
|
|
238
|
+
|
|
239
|
+
false
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def extract_response_headers(response)
|
|
243
|
+
# Try to extract headers if the response object exposes them
|
|
244
|
+
if response.respond_to?(:headers)
|
|
245
|
+
BaseInterceptor.clean_response_headers(response.headers)
|
|
246
|
+
elsif response.respond_to?(:response_headers)
|
|
247
|
+
BaseInterceptor.clean_response_headers(response.response_headers)
|
|
248
|
+
else
|
|
249
|
+
# Anthropic gem doesn't expose response headers directly
|
|
250
|
+
# Return empty hash to indicate no headers are available
|
|
251
|
+
{}
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
module MessageStreamInterceptor
|
|
257
|
+
def accumulated_message
|
|
258
|
+
# Call the original method to get the accumulated message
|
|
259
|
+
message = super
|
|
260
|
+
|
|
261
|
+
# Log the completion data if we have streaming request metadata
|
|
262
|
+
streaming_request = Thread.current[:coolhand_streaming_request]
|
|
263
|
+
log_streaming_completion(message, streaming_request) if streaming_request
|
|
264
|
+
|
|
265
|
+
message
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
def log_streaming_completion(message, streaming_request)
|
|
271
|
+
# Convert message to hash for logging (preserving natural format)
|
|
272
|
+
response_body = extract_response_data(message)
|
|
273
|
+
|
|
274
|
+
# Send the completion log
|
|
275
|
+
BaseInterceptor.send_complete_request_log(
|
|
276
|
+
request_id: streaming_request[:request_id],
|
|
277
|
+
method: streaming_request[:method],
|
|
278
|
+
url: streaming_request[:url],
|
|
279
|
+
request_headers: streaming_request[:request_headers],
|
|
280
|
+
request_body: streaming_request[:request_body],
|
|
281
|
+
response_headers: {},
|
|
282
|
+
response_body: response_body,
|
|
283
|
+
status_code: nil,
|
|
284
|
+
start_time: streaming_request[:start_time],
|
|
285
|
+
end_time: streaming_request[:end_time],
|
|
286
|
+
duration_ms: streaming_request[:duration_ms],
|
|
287
|
+
is_streaming: streaming_request[:is_streaming]
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Clear the thread-local data
|
|
291
|
+
Thread.current[:coolhand_streaming_request] = nil
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def extract_response_data(message)
|
|
295
|
+
BaseInterceptor.extract_response_data(message)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -8,12 +8,27 @@ require_relative "collector"
|
|
|
8
8
|
module Coolhand
|
|
9
9
|
module Ruby
|
|
10
10
|
class ApiService
|
|
11
|
-
BASE_URI = "https://
|
|
11
|
+
BASE_URI = "https://coolhandlabs.com/api"
|
|
12
12
|
|
|
13
13
|
attr_reader :api_endpoint
|
|
14
14
|
|
|
15
|
-
def initialize(endpoint_path)
|
|
16
|
-
@api_endpoint = "#{BASE_URI}/#{endpoint_path}"
|
|
15
|
+
def initialize(endpoint_path = nil)
|
|
16
|
+
@api_endpoint = endpoint_path ? "#{BASE_URI}/#{endpoint_path}" : BASE_URI
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def send_llm_request_log(request_data)
|
|
20
|
+
original_endpoint = @api_endpoint
|
|
21
|
+
@api_endpoint = "#{BASE_URI}/v2/llm_request_logs"
|
|
22
|
+
|
|
23
|
+
payload = {
|
|
24
|
+
llm_request_log: request_data.merge(
|
|
25
|
+
collector: Collector.get_collector_string
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
result = send_request(payload, "✅ Successfully sent request metadata")
|
|
30
|
+
@api_endpoint = original_endpoint
|
|
31
|
+
result
|
|
17
32
|
end
|
|
18
33
|
|
|
19
34
|
def configuration
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Coolhand
|
|
7
|
+
module Ruby
|
|
8
|
+
# Base module with common functionality for all interceptors
|
|
9
|
+
module BaseInterceptor
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def extract_response_data(response)
|
|
13
|
+
case response
|
|
14
|
+
when Hash
|
|
15
|
+
response
|
|
16
|
+
when Struct
|
|
17
|
+
response.to_h
|
|
18
|
+
else
|
|
19
|
+
# Handle streaming responses - these are often enumerator objects
|
|
20
|
+
# that can't be serialized directly
|
|
21
|
+
if response.class.name.include?("Stream") || response.respond_to?(:each)
|
|
22
|
+
{
|
|
23
|
+
response_type: "streaming",
|
|
24
|
+
class: response.class.name,
|
|
25
|
+
note: "Streaming response - content captured during enumeration"
|
|
26
|
+
}
|
|
27
|
+
elsif response.respond_to?(:to_h)
|
|
28
|
+
begin
|
|
29
|
+
response.to_h
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
{
|
|
32
|
+
serialization_error: e.message,
|
|
33
|
+
class: response.class.name,
|
|
34
|
+
raw_response: response.to_s
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
else
|
|
38
|
+
# Extract content and token usage information
|
|
39
|
+
response_data = {}
|
|
40
|
+
|
|
41
|
+
# Get content
|
|
42
|
+
response_data[:content] = response.content if response.respond_to?(:content)
|
|
43
|
+
|
|
44
|
+
# Extract token usage information
|
|
45
|
+
response_data[:usage] = extract_usage_metadata(response.usage) if response.respond_to?(:usage)
|
|
46
|
+
|
|
47
|
+
# Extract model information
|
|
48
|
+
response_data[:model] = response.model if response.respond_to?(:model)
|
|
49
|
+
|
|
50
|
+
# Extract role information
|
|
51
|
+
response_data[:role] = response.role if response.respond_to?(:role)
|
|
52
|
+
|
|
53
|
+
# Extract ID if available
|
|
54
|
+
response_data[:id] = response.id if response.respond_to?(:id)
|
|
55
|
+
|
|
56
|
+
# Extract stop reason if available
|
|
57
|
+
response_data[:stop_reason] = response.stop_reason if response.respond_to?(:stop_reason)
|
|
58
|
+
|
|
59
|
+
# Add class info for debugging
|
|
60
|
+
response_data[:class] = response.class.name
|
|
61
|
+
|
|
62
|
+
response_data.empty? ? { raw_response: response.to_s, class: response.class.name } : response_data
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def extract_usage_metadata(usage)
|
|
68
|
+
if usage.respond_to?(:to_h)
|
|
69
|
+
usage.to_h
|
|
70
|
+
elsif usage.is_a?(Hash)
|
|
71
|
+
usage
|
|
72
|
+
else
|
|
73
|
+
# Extract individual usage fields
|
|
74
|
+
usage_data = {}
|
|
75
|
+
usage_data[:input_tokens] = usage.input_tokens if usage.respond_to?(:input_tokens)
|
|
76
|
+
usage_data[:output_tokens] = usage.output_tokens if usage.respond_to?(:output_tokens)
|
|
77
|
+
usage_data[:total_tokens] = usage_data[:input_tokens].to_i + usage_data[:output_tokens].to_i
|
|
78
|
+
usage_data
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def clean_request_headers(headers)
|
|
83
|
+
cleaned = headers.dup
|
|
84
|
+
|
|
85
|
+
# Remove sensitive headers
|
|
86
|
+
cleaned.delete("Authorization")
|
|
87
|
+
cleaned.delete("authorization")
|
|
88
|
+
cleaned.delete("x-api-key")
|
|
89
|
+
cleaned.delete("X-API-Key")
|
|
90
|
+
|
|
91
|
+
cleaned
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def clean_response_headers(headers)
|
|
95
|
+
# Response headers typically don't contain sensitive data
|
|
96
|
+
# but we can filter if needed
|
|
97
|
+
headers.dup
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def sanitize_headers(headers)
|
|
101
|
+
sanitized = headers.transform_keys(&:to_s).dup
|
|
102
|
+
|
|
103
|
+
if sanitized["Authorization"]
|
|
104
|
+
sanitized["Authorization"] = sanitized["Authorization"].gsub(/Bearer .+/, "Bearer [REDACTED]")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
%w[openai-api-key api-key x-api-key X-API-Key].each do |key|
|
|
108
|
+
sanitized[key] = "[REDACTED]" if sanitized[key]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
sanitized
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:, response_headers:,
|
|
115
|
+
response_body:, status_code:, start_time:, end_time:, duration_ms:, is_streaming:)
|
|
116
|
+
request_data = {
|
|
117
|
+
raw_request: {
|
|
118
|
+
id: request_id,
|
|
119
|
+
timestamp: start_time.iso8601,
|
|
120
|
+
method: method.to_s.downcase,
|
|
121
|
+
url: url,
|
|
122
|
+
headers: request_headers,
|
|
123
|
+
request_body: request_body,
|
|
124
|
+
response_headers: response_headers,
|
|
125
|
+
response_body: response_body,
|
|
126
|
+
status_code: status_code,
|
|
127
|
+
duration_ms: duration_ms,
|
|
128
|
+
completed_at: end_time.iso8601,
|
|
129
|
+
is_streaming: is_streaming
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
api_service = Coolhand::Ruby::ApiService.new
|
|
134
|
+
api_service.send_llm_request_log(request_data)
|
|
135
|
+
|
|
136
|
+
Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
Coolhand.log "❌ Error sending complete request log: #{e.message}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def parse_json(string)
|
|
142
|
+
JSON.parse(string)
|
|
143
|
+
rescue JSON::ParserError, TypeError
|
|
144
|
+
string
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_interceptor"
|
|
4
|
+
|
|
5
|
+
module Coolhand
|
|
6
|
+
module Ruby
|
|
7
|
+
class FaradayInterceptor < Faraday::Middleware
|
|
8
|
+
include BaseInterceptor
|
|
9
|
+
|
|
10
|
+
ORIGINAL_METHOD_ALIAS = :coolhand_original_initialize
|
|
11
|
+
|
|
12
|
+
def self.patch!
|
|
13
|
+
return if @patched
|
|
14
|
+
|
|
15
|
+
@patched = true
|
|
16
|
+
Coolhand.log "📡 Monitoring outbound requests ..."
|
|
17
|
+
|
|
18
|
+
# Use prepend instead of alias_method to avoid conflicts with other gems
|
|
19
|
+
Faraday::Connection.prepend(Module.new do
|
|
20
|
+
def initialize(url = nil, options = nil, &block)
|
|
21
|
+
super
|
|
22
|
+
|
|
23
|
+
# Only add interceptor if it's not already present
|
|
24
|
+
unless @builder.handlers.any? { |h| h.klass == Coolhand::Ruby::FaradayInterceptor }
|
|
25
|
+
use Coolhand::Ruby::FaradayInterceptor
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end)
|
|
29
|
+
|
|
30
|
+
Coolhand.log "🔧 Setting up monitoring for Faraday ..."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.unpatch!
|
|
34
|
+
# NOTE: With prepend, there's no clean way to unpatch
|
|
35
|
+
# We'll mark it as unpatched so it can be re-patched
|
|
36
|
+
@patched = false
|
|
37
|
+
Coolhand.log "🔌 Faraday monitoring disabled ..."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.patched?
|
|
41
|
+
@patched
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def call(env)
|
|
45
|
+
# Skip if Faraday interception is temporarily disabled for this thread
|
|
46
|
+
return super if Thread.current[:coolhand_disable_faraday]
|
|
47
|
+
|
|
48
|
+
return super unless llm_api_request?(env)
|
|
49
|
+
|
|
50
|
+
Coolhand.log "🎯 INTERCEPTING OpenAI call #{env.url}"
|
|
51
|
+
|
|
52
|
+
call_data = build_call_data(env)
|
|
53
|
+
buffer = override_on_data(env)
|
|
54
|
+
|
|
55
|
+
process_complete_callback(env, buffer, call_data)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def llm_api_request?(env)
|
|
61
|
+
Coolhand.configuration.intercept_addresses.any? do |address|
|
|
62
|
+
env.url.to_s.include?(address)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_call_data(env)
|
|
67
|
+
{
|
|
68
|
+
id: SecureRandom.uuid,
|
|
69
|
+
timestamp: DateTime.now,
|
|
70
|
+
method: env.method,
|
|
71
|
+
url: env.url.to_s,
|
|
72
|
+
request_headers: sanitize_headers(env.request_headers),
|
|
73
|
+
request_body: parse_json(env.request_body),
|
|
74
|
+
response_body: nil,
|
|
75
|
+
response_headers: nil,
|
|
76
|
+
status_code: nil
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def override_on_data(env)
|
|
81
|
+
buffer = +""
|
|
82
|
+
original_on_data = env.request.on_data
|
|
83
|
+
env.request.on_data = proc do |chunk, overall_received_bytes|
|
|
84
|
+
buffer << chunk
|
|
85
|
+
|
|
86
|
+
original_on_data&.call(chunk, overall_received_bytes)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
buffer
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def process_complete_callback(env, buffer, call_data)
|
|
93
|
+
@app.call(env).on_complete do |response_env|
|
|
94
|
+
if buffer.empty?
|
|
95
|
+
body = response_env.body
|
|
96
|
+
else
|
|
97
|
+
body = buffer
|
|
98
|
+
response_env.body = body
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
call_data[:response_body] = parse_json(body)
|
|
102
|
+
call_data[:response_headers] = sanitize_headers(response_env.response_headers)
|
|
103
|
+
call_data[:status_code] = response_env.status
|
|
104
|
+
|
|
105
|
+
end_time = Time.now
|
|
106
|
+
duration_ms = ((end_time - call_data[:timestamp].to_time) * 1000).round(2)
|
|
107
|
+
|
|
108
|
+
# Send complete request/response data in single API call
|
|
109
|
+
Thread.new do
|
|
110
|
+
send_complete_request_log(
|
|
111
|
+
request_id: call_data[:id],
|
|
112
|
+
method: call_data[:method],
|
|
113
|
+
url: call_data[:url],
|
|
114
|
+
request_headers: call_data[:request_headers],
|
|
115
|
+
request_body: call_data[:request_body],
|
|
116
|
+
response_headers: call_data[:response_headers],
|
|
117
|
+
response_body: call_data[:response_body],
|
|
118
|
+
status_code: call_data[:status_code],
|
|
119
|
+
start_time: call_data[:timestamp],
|
|
120
|
+
end_time: end_time,
|
|
121
|
+
duration_ms: duration_ms,
|
|
122
|
+
is_streaming: false
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
data/lib/coolhand/ruby.rb
CHANGED
|
@@ -8,7 +8,8 @@ require "securerandom"
|
|
|
8
8
|
require_relative "ruby/version"
|
|
9
9
|
require_relative "ruby/configuration"
|
|
10
10
|
require_relative "ruby/collector"
|
|
11
|
-
require_relative "ruby/
|
|
11
|
+
require_relative "ruby/faraday_interceptor"
|
|
12
|
+
require_relative "ruby/anthropic_interceptor"
|
|
12
13
|
require_relative "ruby/api_service"
|
|
13
14
|
require_relative "ruby/logger_service"
|
|
14
15
|
require_relative "ruby/feedback_service"
|
|
@@ -43,10 +44,22 @@ module Coolhand
|
|
|
43
44
|
|
|
44
45
|
configuration.validate!
|
|
45
46
|
|
|
46
|
-
# Apply the patch
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
# Apply the Faraday patch (needed for ruby-anthropic and other Faraday-based gems)
|
|
48
|
+
Ruby::FaradayInterceptor.patch!
|
|
49
|
+
|
|
50
|
+
# Conditionally patch the official Anthropic gem if it's loaded
|
|
51
|
+
if anthropic_gem_loaded?
|
|
52
|
+
if defined?(Anthropic::Internal)
|
|
53
|
+
# Official anthropic gem - patch the AnthropicInterceptor for Net::HTTP requests
|
|
54
|
+
Ruby::AnthropicInterceptor.patch!
|
|
55
|
+
log "✅ Coolhand ready - will log OpenAI and Anthropic (official gem) calls"
|
|
56
|
+
else
|
|
57
|
+
# ruby-anthropic gem uses Faraday, so FaradayInterceptor is sufficient
|
|
58
|
+
log "✅ Coolhand ready - will log OpenAI and Anthropic (ruby-anthropic via Faraday) calls"
|
|
59
|
+
end
|
|
60
|
+
else
|
|
61
|
+
log "✅ Coolhand ready - will log OpenAI calls"
|
|
62
|
+
end
|
|
50
63
|
end
|
|
51
64
|
|
|
52
65
|
def capture
|
|
@@ -55,11 +68,19 @@ module Coolhand
|
|
|
55
68
|
return
|
|
56
69
|
end
|
|
57
70
|
|
|
58
|
-
|
|
71
|
+
Ruby::FaradayInterceptor.patch!
|
|
72
|
+
|
|
73
|
+
# Only patch AnthropicInterceptor for official anthropic gem
|
|
74
|
+
anthropic_patched = false
|
|
75
|
+
if anthropic_gem_loaded? && defined?(Anthropic::Internal)
|
|
76
|
+
Ruby::AnthropicInterceptor.patch!
|
|
77
|
+
anthropic_patched = true
|
|
78
|
+
end
|
|
59
79
|
|
|
60
80
|
yield
|
|
61
81
|
ensure
|
|
62
|
-
|
|
82
|
+
Ruby::FaradayInterceptor.unpatch!
|
|
83
|
+
Ruby::AnthropicInterceptor.unpatch! if anthropic_patched
|
|
63
84
|
end
|
|
64
85
|
|
|
65
86
|
# A simple logger that respects the 'silent' configuration option.
|
|
@@ -86,5 +107,15 @@ module Coolhand
|
|
|
86
107
|
|
|
87
108
|
true
|
|
88
109
|
end
|
|
110
|
+
|
|
111
|
+
def current_request_id
|
|
112
|
+
Thread.current[:coolhand_current_request_id]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def anthropic_gem_loaded?
|
|
118
|
+
defined?(Anthropic::Client)
|
|
119
|
+
end
|
|
89
120
|
end
|
|
90
121
|
end
|