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.
@@ -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://coolhand.io/api"
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Coolhand
4
4
  module Ruby
5
- VERSION = "0.1.4"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  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/interceptor"
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 after configuration is set
47
- Interceptor.patch!
48
-
49
- log "✅ Coolhand ready - will log OpenAI calls"
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
- Interceptor.patch!
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
- Interceptor.unpatch!
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