coolhand 0.1.5 → 0.3.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.
data/lib/coolhand.rb CHANGED
@@ -1,4 +1,111 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Main entry point for the Coolhand gem
4
- require_relative "coolhand/ruby"
3
+ require "uri"
4
+ require "faraday"
5
+ require "securerandom"
6
+ require "json"
7
+ require "base64"
8
+
9
+ require_relative "coolhand/version"
10
+ require_relative "coolhand/configuration"
11
+ require_relative "coolhand/collector"
12
+ require_relative "coolhand/base_interceptor"
13
+ require_relative "coolhand/net_http_interceptor"
14
+ require_relative "coolhand/api_service"
15
+ require_relative "coolhand/logger_service"
16
+ require_relative "coolhand/feedback_service"
17
+ require_relative "coolhand/webhook_interceptor"
18
+
19
+ # The main module for the Coolhand gem.
20
+ # It provides the configuration interface and initializes the patching.
21
+ module Coolhand
22
+ class Error < StandardError; end
23
+
24
+ # Class-level instance variables to hold the configuration
25
+ @configuration = Configuration.new
26
+
27
+ class << self
28
+ attr_reader :configuration
29
+
30
+ # Reset configuration to defaults (mainly for testing)
31
+ def reset_configuration!
32
+ @configuration = Configuration.new
33
+ Thread.current[:coolhand_capture_override] = nil
34
+ end
35
+
36
+ # Provides a block to configure the gem.
37
+ #
38
+ # Example:
39
+ # Coolhand.configure do |config|
40
+ # config.environment = 'development'
41
+ # config.silent = false
42
+ # config.api_key = "xxx-yyy-zzz"
43
+ # config.intercept_addresses = ["openai.com", "api.anthropic.com"]
44
+ # end
45
+ def configure
46
+ yield(configuration)
47
+
48
+ configuration.validate!
49
+
50
+ NetHttpInterceptor.patch!
51
+
52
+ log "✅ Coolhand ready - will log inference calls on monitored URIs"
53
+ end
54
+
55
+ def capture
56
+ unless block_given?
57
+ log "❌ Coolhand Error: Method .capture requires block."
58
+ return
59
+ end
60
+
61
+ patched = NetHttpInterceptor.patched?
62
+
63
+ NetHttpInterceptor.patch!
64
+
65
+ yield
66
+ ensure
67
+ NetHttpInterceptor.unpatch! unless patched
68
+ end
69
+
70
+ def without_capture
71
+ previous = Thread.current[:coolhand_capture_override]
72
+ Thread.current[:coolhand_capture_override] = false
73
+ yield
74
+ ensure
75
+ Thread.current[:coolhand_capture_override] = previous
76
+ end
77
+
78
+ def with_capture
79
+ previous = Thread.current[:coolhand_capture_override]
80
+ Thread.current[:coolhand_capture_override] = true
81
+ yield
82
+ ensure
83
+ Thread.current[:coolhand_capture_override] = previous
84
+ end
85
+
86
+ # A simple logger that respects the 'silent' configuration option.
87
+ def log(message)
88
+ return if configuration.silent
89
+
90
+ puts "COOLHAND: #{message}"
91
+ end
92
+
93
+ # Creates a new FeedbackService instance
94
+ def feedback_service
95
+ FeedbackService.new
96
+ end
97
+
98
+ # Creates a new LoggerService instance
99
+ def logger_service
100
+ LoggerService.new
101
+ end
102
+
103
+ def required_field?(value)
104
+ return false if value.nil?
105
+ return false if value.respond_to?(:empty?) && value.empty?
106
+ return false if value.to_s.strip.empty?
107
+
108
+ true
109
+ end
110
+ end
111
+ end
metadata CHANGED
@@ -1,18 +1,33 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coolhand
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Carroll
8
8
  - Yaroslav Malyk
9
- autorequire:
10
9
  bindir: exe
11
10
  cert_chain: []
12
- date: 2025-12-09 00:00:00.000000000 Z
13
- dependencies: []
14
- description: A Ruby gem to automatically monitor and log external LLM requests. It
15
- patches Net::HTTP to capture request and response data.
11
+ date: 2026-05-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.2'
27
+ description: Automatically intercept and log LLM requests from Ruby applications.
28
+ Supports OpenAI, official Anthropic gem, ruby-anthropic gem, and other Faraday-based
29
+ libraries. Features dual interceptor architecture, streaming support, thread-safe
30
+ operation, and automatic duplicate request prevention.
16
31
  email:
17
32
  - mc@coolhandlabs.com
18
33
  executables: []
@@ -24,20 +39,28 @@ files:
24
39
  - ".rubocop.yml"
25
40
  - ".simplecov"
26
41
  - CHANGELOG.md
42
+ - CLAUDE.md
27
43
  - LICENSE
28
44
  - README.md
29
45
  - Rakefile
30
46
  - coolhand-ruby.gemspec
47
+ - docs/anthropic.md
31
48
  - docs/elevenlabs.md
32
49
  - lib/coolhand.rb
33
- - lib/coolhand/ruby.rb
34
- - lib/coolhand/ruby/api_service.rb
35
- - lib/coolhand/ruby/collector.rb
36
- - lib/coolhand/ruby/configuration.rb
37
- - lib/coolhand/ruby/feedback_service.rb
38
- - lib/coolhand/ruby/interceptor.rb
39
- - lib/coolhand/ruby/logger_service.rb
40
- - lib/coolhand/ruby/version.rb
50
+ - lib/coolhand/api_service.rb
51
+ - lib/coolhand/base_interceptor.rb
52
+ - lib/coolhand/collector.rb
53
+ - lib/coolhand/configuration.rb
54
+ - lib/coolhand/default_exclude_api_patterns.yml
55
+ - lib/coolhand/default_intercept_addresses.yml
56
+ - lib/coolhand/feedback_service.rb
57
+ - lib/coolhand/logger_service.rb
58
+ - lib/coolhand/net_http_interceptor.rb
59
+ - lib/coolhand/open_ai/batch_result_processor.rb
60
+ - lib/coolhand/open_ai/webhook_validator.rb
61
+ - lib/coolhand/version.rb
62
+ - lib/coolhand/vertex/batch_result_processor.rb
63
+ - lib/coolhand/webhook_interceptor.rb
41
64
  - sig/coolhand/ruby.rbs
42
65
  homepage: https://coolhandlabs.com/
43
66
  licenses:
@@ -46,9 +69,8 @@ metadata:
46
69
  allowed_push_host: https://rubygems.org
47
70
  homepage_uri: https://coolhandlabs.com/
48
71
  source_code_uri: https://github.com/Coolhand-Labs/coolhand-ruby
49
- changelog_uri: https://github.com/Coolhand-Labs/coolhand-ruby
72
+ changelog_uri: https://github.com/Coolhand-Labs/coolhand-ruby/blob/main/CHANGELOG.md
50
73
  rubygems_mfa_required: 'true'
51
- post_install_message:
52
74
  rdoc_options: []
53
75
  require_paths:
54
76
  - lib
@@ -63,8 +85,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
63
85
  - !ruby/object:Gem::Version
64
86
  version: '0'
65
87
  requirements: []
66
- rubygems_version: 3.3.26
67
- signing_key:
88
+ rubygems_version: 3.6.2
68
89
  specification_version: 4
69
- summary: Intercepts and logs OpenAI API calls from a Ruby application.
90
+ summary: Monitor and log LLM API calls from OpenAI, Anthropic, and other providers
91
+ to Coolhand analytics.
70
92
  test_files: []
@@ -1,211 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "net/http"
4
- require "uri"
5
- require "json"
6
- require_relative "collector"
7
-
8
- module Coolhand
9
- module Ruby
10
- class ApiService
11
- BASE_URI = "https://coolhandlabs.com/api"
12
-
13
- attr_reader :api_endpoint
14
-
15
- def initialize(endpoint_path)
16
- @api_endpoint = "#{BASE_URI}/#{endpoint_path}"
17
- end
18
-
19
- def configuration
20
- Coolhand.configuration
21
- end
22
-
23
- def api_key
24
- configuration.api_key
25
- end
26
-
27
- def silent
28
- configuration.silent
29
- end
30
-
31
- protected
32
-
33
- # Add collector field to the data being sent
34
- def add_collector_to_data(data, collection_method = nil)
35
- data.merge(collector: Collector.get_collector_string(collection_method))
36
- end
37
-
38
- def create_request_options(_payload)
39
- {
40
- "Content-Type" => "application/json",
41
- "X-API-Key" => api_key
42
- }
43
- end
44
-
45
- def send_request(payload, success_message)
46
- uri = URI.parse(@api_endpoint)
47
- http = Net::HTTP.new(uri.host, uri.port)
48
- http.use_ssl = (uri.scheme == "https")
49
-
50
- request = Net::HTTP::Post.new(uri.request_uri)
51
- headers = create_request_options(payload)
52
- headers.each do |key, value|
53
- # Ensure header values are UTF-8 encoded
54
- encoded_value = value.is_a?(String) ? value.dup.force_encoding("UTF-8") : value
55
- request[key] = encoded_value
56
- end
57
-
58
- # Clean payload and ensure UTF-8 encoding before JSON generation
59
- cleaned_payload = sanitize_payload_for_json(payload)
60
- json_body = JSON.generate(cleaned_payload)
61
-
62
- # Ensure the request body is properly encoded as UTF-8
63
- request.body = json_body.force_encoding("UTF-8")
64
-
65
- begin
66
- response = http.request(request)
67
-
68
- if response.is_a?(Net::HTTPSuccess)
69
- result = JSON.parse(response.body, symbolize_names: true)
70
- log success_message
71
- result
72
- else
73
- body = response.body.force_encoding("UTF-8") if response.body
74
- puts "❌ Request failed: #{response.code} - #{body}"
75
- nil
76
- end
77
- rescue StandardError => e
78
- log "❌ Request error: #{e.message}"
79
- nil
80
- end
81
- end
82
-
83
- def log(*args)
84
- puts args.join(" ") unless silent
85
- end
86
-
87
- def log_separator
88
- log("═" * 60) unless silent
89
- end
90
-
91
- def create_feedback(feedback, collection_method = nil)
92
- feedback_with_collector = add_collector_to_data(feedback, collection_method)
93
-
94
- payload = {
95
- llm_request_log_feedback: feedback_with_collector
96
- }
97
-
98
- log_feedback_info(feedback)
99
-
100
- result = send_request(
101
- payload,
102
- "✅ Successfully created feedback with ID: #{feedback[:llm_request_log_id] || 'N/A'}"
103
- )
104
-
105
- log_separator
106
- result
107
- end
108
-
109
- def create_log(captured_data, collection_method = nil)
110
- raw_request_with_collector = add_collector_to_data({ raw_request: captured_data }, collection_method)
111
-
112
- payload = {
113
- llm_request_log: raw_request_with_collector
114
- }
115
-
116
- log_request_info(captured_data)
117
-
118
- result = send_request(
119
- payload,
120
- "✅ Successfully logged to API"
121
- )
122
-
123
- puts "✅ Successfully logged to API with ID: #{result[:id]}" if result && !silent
124
-
125
- log_separator
126
- result
127
- end
128
-
129
- # Filter list of known binary/problematic field names by service
130
- BINARY_DATA_FILTERS = {
131
- # ElevenLabs fields that contain binary audio data
132
- elevenlabs: %w[
133
- full_audio
134
- audio
135
- audio_data
136
- raw_audio
137
- audio_base64
138
- voice_sample
139
- audio_url
140
- ],
141
- # OpenAI fields that might contain binary data
142
- openai: %w[
143
- file_content
144
- audio_data
145
- image_data
146
- binary_content
147
- ]
148
- }.freeze
149
-
150
- private
151
-
152
- # Get all filtered field names as a flat array
153
- def filtered_field_names
154
- @filtered_field_names ||= BINARY_DATA_FILTERS.values.flatten.map(&:downcase)
155
- end
156
-
157
- # Recursively sanitize payload to remove known problematic fields
158
- def sanitize_payload_for_json(obj)
159
- case obj
160
- when Hash
161
- obj.each_with_object({}) do |(key, value), sanitized|
162
- key_str = key.to_s.downcase
163
-
164
- # Skip if key matches any filtered field name
165
- next if filtered_field_names.any? { |filter| key_str.include?(filter) }
166
-
167
- sanitized[key] = sanitize_payload_for_json(value)
168
- end
169
- when Array
170
- obj.map { |item| sanitize_payload_for_json(item) }
171
- else
172
- obj
173
- end
174
- rescue StandardError => e
175
- log "⚠️ Warning: Error sanitizing payload: #{e.message}"
176
- obj
177
- end
178
-
179
- def log_feedback_info(feedback)
180
- return if silent
181
-
182
- # Log the appropriate identifier based on what was provided
183
- if feedback[:llm_request_log_id]
184
- puts "\n📝 CREATING FEEDBACK for LLM Request Log ID: #{feedback[:llm_request_log_id]}"
185
- elsif feedback[:llm_provider_unique_id]
186
- puts "\n📝 CREATING FEEDBACK for Provider Unique ID: #{feedback[:llm_provider_unique_id]}"
187
- else
188
- puts "\n📝 CREATING FEEDBACK"
189
- end
190
-
191
- puts "👍/👎 Like: #{feedback[:like]}"
192
-
193
- if feedback[:explanation]
194
- explanation = feedback[:explanation]
195
- truncated = explanation.length > 100 ? "#{explanation[0..99]}..." : explanation
196
- puts "💭 Explanation: #{truncated}"
197
- end
198
-
199
- puts "📤 Sending to: #{@api_endpoint}"
200
- end
201
-
202
- def log_request_info(captured_data)
203
- return if silent
204
-
205
- puts "\n🎉 LOGGING OpenAI API Call #{@api_endpoint}"
206
- puts captured_data
207
- puts "📤 Sending to: #{@api_endpoint}"
208
- end
209
- end
210
- end
211
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Coolhand
4
- module Ruby
5
- # Utility for generating collector identification string
6
- module Collector
7
- COLLECTION_METHODS = %w[manual auto-monitor].freeze
8
-
9
- class << self
10
- # Gets the collector identification string
11
- # Format: "coolhand-ruby-X.Y.Z" or "coolhand-ruby-X.Y.Z-method"
12
- # @param method [String, nil] Optional collection method suffix
13
- # @return [String] Collector string identifying this SDK version and collection method
14
- def get_collector_string(method = nil)
15
- base = "coolhand-ruby-#{VERSION}"
16
- method && COLLECTION_METHODS.include?(method) ? "#{base}-#{method}" : base
17
- end
18
- end
19
- end
20
- end
21
- end
@@ -1,38 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Coolhand
4
- # Handles all configuration settings for the gem.
5
- class Configuration
6
- attr_accessor :api_key, :environment, :silent
7
- attr_reader :intercept_addresses
8
-
9
- def initialize
10
- # Set defaults
11
- @environment = "production"
12
- @api_key = nil
13
- @silent = false
14
- @intercept_addresses = ["api.openai.com", "api.anthropic.com", "api.elevenlabs.io", ":generateContent"]
15
- end
16
-
17
- # Custom setter that preserves defaults when nil/empty array is provided
18
- def intercept_addresses=(value)
19
- return if value.nil? || (value.is_a?(Array) && value.empty?)
20
-
21
- @intercept_addresses = value.is_a?(Array) ? value : [value]
22
- end
23
-
24
- def validate!
25
- # Validate API Key after configuration
26
- if api_key.nil?
27
- Coolhand.log "❌ Coolhand Error: API Key is required. Please set it in the configuration."
28
- raise Error, "API Key is required"
29
- end
30
-
31
- # Validate intercept_addresses after configuration
32
- if intercept_addresses.nil? || intercept_addresses.empty?
33
- Coolhand.log "❌ Coolhand Error: Intercept addresses cannot be empty. Please set it in the configuration."
34
- raise Error, "Intercept addresses cannot be empty"
35
- end
36
- end
37
- end
38
- end
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "api_service"
4
-
5
- module Coolhand
6
- module Ruby
7
- class FeedbackService < ApiService
8
- def initialize
9
- super("v2/llm_request_log_feedbacks")
10
- end
11
-
12
- def create_feedback(feedback)
13
- super(feedback, "manual")
14
- end
15
- end
16
- end
17
- end
@@ -1,119 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Coolhand
4
- class Interceptor < Faraday::Middleware
5
- ORIGINAL_METHOD_ALIAS = :coolhand_original_initialize
6
-
7
- def self.patch!
8
- return if @patched
9
-
10
- @patched = true
11
- Coolhand.log "📡 Monitoring outbound requests ..."
12
-
13
- # Use prepend instead of alias_method to avoid conflicts with other gems
14
- Faraday::Connection.prepend(Module.new do
15
- def initialize(url = nil, options = nil, &block)
16
- super
17
-
18
- # Only add interceptor if it's not already present
19
- use Coolhand::Interceptor unless @builder.handlers.any? { |h| h.klass == Coolhand::Interceptor }
20
- end
21
- end)
22
-
23
- Coolhand.log "🔧 Setting up monitoring for Faraday ..."
24
- end
25
-
26
- def self.unpatch!
27
- # NOTE: With prepend, there's no clean way to unpatch
28
- # We'll mark it as unpatched so it can be re-patched
29
- @patched = false
30
- Coolhand.log "🔌 Faraday monitoring disabled ..."
31
- end
32
-
33
- def self.patched?
34
- @patched
35
- end
36
-
37
- def call(env)
38
- return super unless llm_api_request?(env)
39
-
40
- Coolhand.log "🎯 INTERCEPTING OpenAI call #{env.url}"
41
-
42
- call_data = build_call_data(env)
43
- buffer = override_on_data(env)
44
-
45
- process_complete_callback(env, buffer, call_data)
46
- end
47
-
48
- private
49
-
50
- def llm_api_request?(env)
51
- Coolhand.configuration.intercept_addresses.any? do |address|
52
- env.url.to_s.include?(address)
53
- end
54
- end
55
-
56
- def build_call_data(env)
57
- {
58
- id: SecureRandom.uuid,
59
- timestamp: DateTime.now,
60
- method: env.method,
61
- url: env.url.to_s,
62
- headers: sanitize_headers(env.request_headers),
63
- request_body: parse_json(env.request_body),
64
- response_body: nil,
65
- response_headers: nil,
66
- status_code: nil
67
- }
68
- end
69
-
70
- def override_on_data(env)
71
- buffer = +""
72
- original_on_data = env.request.on_data
73
- env.request.on_data = proc do |chunk, overall_received_bytes|
74
- buffer << chunk
75
-
76
- original_on_data&.call(chunk, overall_received_bytes)
77
- end
78
-
79
- buffer
80
- end
81
-
82
- def process_complete_callback(env, buffer, call_data)
83
- @app.call(env).on_complete do |response_env|
84
- if buffer.empty?
85
- body = response_env.body
86
- else
87
- body = buffer
88
- response_env.body = body
89
- end
90
-
91
- call_data[:response_body] = parse_json(body)
92
- call_data[:response_headers] = sanitize_headers(response_env.request_headers)
93
- call_data[:status_code] = response_env.status
94
-
95
- Thread.new { Coolhand.logger_service.log_to_api(call_data) }
96
- end
97
- end
98
-
99
- def parse_json(string)
100
- JSON.parse(string)
101
- rescue JSON::ParserError, TypeError
102
- string
103
- end
104
-
105
- def sanitize_headers(headers)
106
- sanitized = headers.transform_keys(&:to_s).dup
107
-
108
- if sanitized["Authorization"]
109
- sanitized["Authorization"] = sanitized["Authorization"].gsub(/Bearer .+/, "Bearer [REDACTED]")
110
- end
111
-
112
- %w[openai-api-key api-key].each do |key|
113
- sanitized[key] = "[REDACTED]" if sanitized[key]
114
- end
115
-
116
- sanitized
117
- end
118
- end
119
- end