coolhand 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 193fc72a2e4d3c485584250f6f846ba97dbd02f55c357e8bf6f3f95641b669d5
4
- data.tar.gz: 4523f4af59e1a81a7763edd5424e86bec7bd35eb40799af7ad253446d9c86bbe
3
+ metadata.gz: 4b8242433c93dec62fb833fc641c5917f6bb68c01e59e3061f2ac6300ec674c5
4
+ data.tar.gz: 7e6019bc86a3268df2ddebf4e0a9cfcabd32a72e3e8618a473c2035921aa0ff0
5
5
  SHA512:
6
- metadata.gz: 6d65bfde6461c4f9676a2e0c64de863204e075abae9b762666119bad7676d21ff962e9aeafb3d0420e86ee9d00e0dcda9e266f99dbfc751b289e5e3c91e028cf
7
- data.tar.gz: 48aa86e01741e1a93faec921e65e13a3d434740f91c0c564d22406279d15eab56f60f1aea1bebb5d9c5eb1095200c459b8e728cbd41cc118ee359edfb9eccec1
6
+ metadata.gz: 2255984a1e9238a8bfd1fedecf19aece7a552b25635873ec51803390ae679d206eb69dd0ff31d9df3d6169d78d9ba7503321eaf4bd048b2e59d3337b5c05758c
7
+ data.tar.gz: 44a5dc44c6a74385adf1e70057bf49f463c0401bea5f69b5850d697b414292fe0f9ebe208903f46325db80e0a638bf548c4a1dfcebe0d3b808f67a32b2167cdf
data/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.3] - 2024-10-23
9
+
10
+ ### ✨ New Features
11
+ - **Collector Identifier** - Added collector field to all API calls to identify SDK version (format: `coolhand-ruby-X.Y.Z`)
12
+ - **Collection Method Tracking** - Support for optional collection method suffix (`manual`, `auto-monitor`)
13
+
14
+ ### 🏗️ Internal Improvements
15
+ - **Added Collector Module** - New `Coolhand::Ruby::Collector` module for generating SDK identification strings
16
+ - **Updated ApiService** - Base service now automatically adds collector field to all API payloads
17
+ - **Enhanced Logging** - Both LoggerService and FeedbackService now send collector information
18
+
8
19
  ## [0.1.2] - 2024-10-22
9
20
 
10
21
  ### 🔧 Configuration Improvements
data/README.md CHANGED
@@ -24,7 +24,7 @@ gem 'coolhand'
24
24
 
25
25
  ```ruby
26
26
  # Add this configuration at the start of your application
27
- require 'coolhand'
27
+ require 'coolhand/ruby'
28
28
 
29
29
  Coolhand.configure do |config|
30
30
  config.api_key = 'your_api_key_here'
@@ -52,7 +52,7 @@ end
52
52
  Collect feedback on LLM responses to improve model performance:
53
53
 
54
54
  ```ruby
55
- require 'coolhand'
55
+ require 'coolhand/ruby'
56
56
 
57
57
  # Create feedback for an LLM response
58
58
  feedback_service = Coolhand::Ruby::FeedbackService.new(Coolhand.configuration)
@@ -164,7 +164,7 @@ end
164
164
 
165
165
  ```ruby
166
166
  require 'openai'
167
- require 'coolhand'
167
+ require 'coolhand/ruby'
168
168
 
169
169
  # Configure Coolhand
170
170
  Coolhand.configure do |config|
@@ -190,7 +190,7 @@ puts response.dig("choices", 0, "message", "content")
190
190
 
191
191
  ```ruby
192
192
  require 'anthropic'
193
- require 'coolhand'
193
+ require 'coolhand/ruby'
194
194
 
195
195
  # Configure Coolhand
196
196
  Coolhand.configure do |config|
@@ -210,6 +210,50 @@ puts response["content"]
210
210
  # Automatically logged to Coolhand!
211
211
  ```
212
212
 
213
+ ## Logging Inbound Webhooks
214
+
215
+ For inbound webhooks (like audio transcripts or tool calls), the automatic interceptor won't capture them since they're incoming requests TO your application. In these cases, use the simple `forward_webhook` helper method:
216
+
217
+ ### Webhook Forwarding Example (Recommended)
218
+
219
+ ```ruby
220
+ class WebhooksController < ApplicationController
221
+ def elevenlabs
222
+ raw_body = request.body.read
223
+ webhook_data = JSON.parse(raw_body)
224
+
225
+ # Forward to Coolhand with automatic field generation and binary filtering
226
+ Thread.new do
227
+ Coolhand.logger_service.forward_webhook(
228
+ webhook_body: webhook_data, # Required: webhook payload
229
+ source: "elevenlabs", # Required: service name
230
+ event_type: webhook_data["type"], # Optional: e.g., post_call_transcription
231
+ headers: request.headers, # Optional & recommended
232
+ )
233
+ end
234
+
235
+ render json: { status: "success" }
236
+ end
237
+ end
238
+ ```
239
+
240
+ **Required Parameters:**
241
+ - `webhook_body` - The webhook payload (Hash or parsed JSON)
242
+ - `source` - Service name (String, e.g., "elevenlabs", "stripe", "twilio")
243
+
244
+ **Optional Parameters:**
245
+ - `event_type` - Event type to append to URL (e.g., "post_call_transcription" → `webhook://elevenlabs/post_call_transcription`)
246
+ - `headers` - Request headers (automatically sanitized)
247
+ - `conversation_id`, `agent_id`, `metadata` - Custom fields for your tracking needs
248
+
249
+ **Error Handling:**
250
+ - **Silent mode = false**: Raises `ArgumentError` if required parameters are missing
251
+ - **Silent mode = true**: Logs warning and returns `false` if required parameters are missing
252
+
253
+ That's it! The `forward_webhook` method automatically:
254
+ - ✅ Generates unique ID and timestamp
255
+ - ✅ Filters out binary data (audio, images, etc.)
256
+
213
257
  ## What Gets Logged
214
258
 
215
259
  The monitor captures:
@@ -221,6 +265,24 @@ The monitor captures:
221
265
 
222
266
  Headers containing API keys are automatically sanitized for security.
223
267
 
268
+ ## Binary Data Filtering
269
+
270
+ **Coolhand does not track or store binary data.** The gem automatically filters out:
271
+
272
+ - Audio files and data (`audio`, `audio_data`, `full_audio`, `raw_audio`)
273
+ - Image data (`image_data`, `image_content`)
274
+ - File content (`file_content`, `binary_content`)
275
+ - Base64 encoded data (`audio_base64`, `base64_data`)
276
+ - Voice samples and audio URLs
277
+
278
+ This ensures:
279
+ - ✅ **Smaller payloads** - Only text and metadata are sent
280
+ - ✅ **Faster processing** - No bandwidth wasted on binary data
281
+ - ✅ **Privacy focused** - Audio/video content never leaves your infrastructure
282
+ - ✅ **Clean logs** - Focus on conversational data, not media files
283
+
284
+ The filtering is automatic and applies to all monitored API calls and webhook logging.
285
+
224
286
  ## Supported Libraries
225
287
 
226
288
  The monitor works with any Ruby library that uses Faraday for HTTP(S) requests to LLM APIs, including:
@@ -276,7 +338,7 @@ For standard Ruby scripts or non-Rails applications:
276
338
 
277
339
  ```ruby
278
340
  #!/usr/bin/env ruby
279
- require 'coolhand'
341
+ require 'coolhand/ruby'
280
342
 
281
343
  Coolhand.configure do |config|
282
344
  config.api_key = 'your_api_key_here' # Store securely, don't commit to git
@@ -305,6 +367,10 @@ The monitor handles errors gracefully:
305
367
  - Invalid API keys will be reported but won't crash your app
306
368
  - Network issues are handled with appropriate error messages
307
369
 
370
+ ## Integration Guides
371
+
372
+ - **[ElevenLabs Integration](docs/elevenlabs.md)** - Complete guide for integrating ElevenLabs Conversational AI with webhook capture and feedback submission
373
+
308
374
  ## Security
309
375
 
310
376
  - API keys in request headers are automatically redacted
@@ -324,4 +390,4 @@ The monitor handles errors gracefully:
324
390
 
325
391
  ## License
326
392
 
327
- Apache-2.0
393
+ Apache-2.0
@@ -0,0 +1,408 @@
1
+ # ElevenLabs Integration Guide
2
+
3
+ This guide explains how to integrate Coolhand with ElevenLabs Conversational AI, including webhook capture and feedback submission from the widget.
4
+
5
+ ## Table of Contents
6
+ - [Overview](#overview)
7
+ - [Webhook Integration](#webhook-integration)
8
+ - [Feedback Collection](#feedback-collection)
9
+ - [Complete Example](#complete-example)
10
+
11
+ ## Overview
12
+
13
+ ElevenLabs Conversational AI provides voice-based AI assistants. This integration allows you to:
14
+ 1. Capture conversation data via webhooks
15
+ 2. Fetch and submit user feedback to Coolhand
16
+ 3. Track conversation quality and user satisfaction
17
+
18
+ ## Webhook Integration
19
+
20
+ ### Setting Up Webhook Capture
21
+
22
+ ElevenLabs sends webhooks when conversations occur. Here's how to capture and forward them to Coolhand:
23
+
24
+ #### 1. Create a Webhook Controller
25
+
26
+ ```ruby
27
+ # app/controllers/webhooks_controller.rb
28
+ class WebhooksController < ApplicationController
29
+ skip_before_action :verify_authenticity_token
30
+
31
+ def elevenlabs
32
+ begin
33
+ # Forward the webhook to Coolhand
34
+ result = Coolhand.logger_service.forward_webhook(
35
+ webhook_body: request.raw_post || request.body.read,
36
+ source: "elevenlabs",
37
+ event_type: request.headers["X-ElevenLabs-Event"] || "conversation",
38
+ headers: request.headers
39
+ )
40
+
41
+ if result
42
+ # Optionally save conversation data locally
43
+ save_conversation_transcript(params) if params[:type] == "conversation.finished"
44
+
45
+ render json: { status: "success" }, status: :ok
46
+ else
47
+ render json: { status: "error", message: "Failed to forward webhook" }, status: :unprocessable_entity
48
+ end
49
+ rescue => e
50
+ Rails.logger.error "Webhook processing error: #{e.message}"
51
+ render json: { status: "error", message: e.message }, status: :internal_server_error
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def save_conversation_transcript(webhook_data)
58
+ ConversationTranscript.create!(
59
+ conversation_id: webhook_data.dig(:conversation, :conversation_id),
60
+ transcript: extract_transcript(webhook_data),
61
+ user_message: extract_user_message(webhook_data),
62
+ assistant_message: extract_assistant_message(webhook_data),
63
+ timestamp: Time.current
64
+ )
65
+ end
66
+
67
+ def extract_transcript(data)
68
+ transcript = data.dig(:conversation, :transcript) || []
69
+ transcript.map { |entry| "#{entry[:role]}: #{entry[:message]}" }.join("\n")
70
+ end
71
+
72
+ def extract_user_message(data)
73
+ transcript = data.dig(:conversation, :transcript) || []
74
+ user_entries = transcript.select { |entry| entry[:role] == "user" }
75
+ user_entries.map { |entry| entry[:message] }.join(" ")
76
+ end
77
+
78
+ def extract_assistant_message(data)
79
+ transcript = data.dig(:conversation, :transcript) || []
80
+ agent_entries = transcript.select { |entry| entry[:role] == "agent" }
81
+ agent_entries.map { |entry| entry[:message] }.join(" ")
82
+ end
83
+ end
84
+ ```
85
+
86
+ #### 2. Configure Routes
87
+
88
+ ```ruby
89
+ # config/routes.rb
90
+ Rails.application.routes.draw do
91
+ post "webhooks/elevenlabs", to: "webhooks#elevenlabs"
92
+ end
93
+ ```
94
+
95
+ #### 3. Configure Coolhand for ElevenLabs
96
+
97
+ ```ruby
98
+ # config/initializers/coolhand.rb
99
+ Coolhand.configure do |config|
100
+ config.api_key = ENV["COOLHAND_API_KEY"]
101
+ config.environment = Rails.env
102
+ config.silent = false
103
+
104
+ # Include ElevenLabs API in intercept addresses
105
+ config.intercept_addresses = [
106
+ "api.openai.com",
107
+ "api.anthropic.com",
108
+ "api.elevenlabs.io"
109
+ ]
110
+ end
111
+ ```
112
+
113
+ ## Feedback Collection
114
+
115
+ ### Fetching Feedback from ElevenLabs API
116
+
117
+ ElevenLabs stores conversation feedback in the metadata field. Here's how to retrieve and submit it to Coolhand:
118
+
119
+ #### 1. Create ElevenLabs API Service
120
+
121
+ ```ruby
122
+ # app/services/elevenlabs_api_service.rb
123
+ require 'net/http'
124
+ require 'uri'
125
+ require 'json'
126
+
127
+ class ElevenlabsApiService
128
+ BASE_URL = 'https://api.elevenlabs.io'
129
+ API_KEY = ENV['ELEVENLABS_API_KEY']
130
+
131
+ def initialize
132
+ unless API_KEY
133
+ raise "ElevenLabs API key is required. Please set ELEVENLABS_API_KEY environment variable."
134
+ end
135
+ end
136
+
137
+ # Fetch conversation data from ElevenLabs
138
+ def fetch_conversation(conversation_id)
139
+ url = "#{BASE_URL}/v1/convai/conversations/#{conversation_id}"
140
+ uri = URI(url)
141
+
142
+ http = Net::HTTP.new(uri.host, uri.port)
143
+ http.use_ssl = true
144
+
145
+ request = Net::HTTP::Get.new(uri)
146
+ request['xi-api-key'] = API_KEY
147
+ request['Content-Type'] = 'application/json'
148
+
149
+ response = http.request(request)
150
+
151
+ if response.code == '200'
152
+ JSON.parse(response.body)
153
+ else
154
+ Rails.logger.error "ElevenLabs API error: #{response.code} - #{response.body}"
155
+ raise "Failed to fetch conversation: #{response.code}"
156
+ end
157
+ rescue => e
158
+ Rails.logger.error "Error fetching conversation from ElevenLabs: #{e.message}"
159
+ raise e
160
+ end
161
+
162
+ # Extract feedback data from conversation response
163
+ def extract_feedback(conversation_data)
164
+ feedback_data = {}
165
+
166
+ # Feedback is located in metadata.feedback
167
+ if conversation_data.dig('metadata', 'feedback')
168
+ feedback = conversation_data['metadata']['feedback']
169
+
170
+ # Extract rating (numerical score)
171
+ if feedback['rating']
172
+ feedback_data[:feedback_rating] = feedback['rating']
173
+ end
174
+
175
+ # Extract comment (text feedback)
176
+ if feedback['comment'] && !feedback['comment'].empty?
177
+ feedback_data[:feedback_text] = feedback['comment']
178
+ end
179
+ end
180
+
181
+ # Return nil if no feedback found
182
+ return nil if feedback_data.empty?
183
+
184
+ feedback_data
185
+ rescue => e
186
+ Rails.logger.error "Error extracting feedback: #{e.message}"
187
+ nil
188
+ end
189
+ end
190
+ ```
191
+
192
+ #### 2. Create Feedback Controller
193
+
194
+ ```ruby
195
+ # app/controllers/transcripts_controller.rb
196
+ class TranscriptsController < ApplicationController
197
+ def index
198
+ @transcripts = ConversationTranscript.order(timestamp: :desc)
199
+ end
200
+
201
+ def fetch_feedback
202
+ conversation_id = params[:conversation_id]
203
+
204
+ begin
205
+ # Initialize ElevenLabs API service
206
+ elevenlabs_service = ElevenlabsApiService.new
207
+
208
+ # Fetch conversation data from ElevenLabs
209
+ conversation_data = elevenlabs_service.fetch_conversation(conversation_id)
210
+
211
+ # Extract feedback from the response
212
+ feedback_data = elevenlabs_service.extract_feedback(conversation_data)
213
+
214
+ if feedback_data
215
+ # Submit feedback to Coolhand using llm_provider_unique_id for matching
216
+ coolhand_feedback = {
217
+ like: feedback_data[:feedback_rating],
218
+ explanation: feedback_data[:feedback_text],
219
+ llm_provider_unique_id: conversation_id # Important: use this field for matching
220
+ }
221
+
222
+ result = Coolhand.feedback_service.create_feedback(coolhand_feedback)
223
+
224
+ if result
225
+ flash[:success] = "Feedback successfully fetched from ElevenLabs and submitted to Coolhand!"
226
+ else
227
+ flash[:error] = "Failed to submit feedback to Coolhand"
228
+ end
229
+ else
230
+ flash[:warning] = "No feedback found for this conversation in ElevenLabs"
231
+ end
232
+
233
+ rescue => e
234
+ flash[:error] = "Error fetching feedback: #{e.message}"
235
+ end
236
+
237
+ redirect_to transcripts_path
238
+ end
239
+ end
240
+ ```
241
+
242
+ #### 3. Create UI for Feedback Submission
243
+
244
+ ```erb
245
+ <!-- app/views/transcripts/index.html.erb -->
246
+ <div class="container">
247
+ <h1>Voice Chat Transcripts</h1>
248
+
249
+ <% if @transcripts.any? %>
250
+ <div class="transcripts-list">
251
+ <% @transcripts.each do |transcript| %>
252
+ <div class="transcript-item">
253
+ <div class="transcript-header">
254
+ <div class="header-info">
255
+ <strong>Conversation ID:</strong> <%= transcript.conversation_id %>
256
+ <span class="timestamp"><%= transcript.timestamp&.strftime("%m/%d/%Y %I:%M %p") %></span>
257
+ </div>
258
+ <div class="header-actions">
259
+ <%= form_with url: "/transcripts/fetch_feedback", method: :post, local: true do |form| %>
260
+ <%= form.hidden_field :conversation_id, value: transcript.conversation_id %>
261
+ <%= form.submit "Fetch Feedback", class: "btn btn-primary btn-sm" %>
262
+ <% end %>
263
+ </div>
264
+ </div>
265
+
266
+ <% if transcript.transcript.present? %>
267
+ <div class="message full-transcript">
268
+ <span class="label">Transcript:</span> <%= transcript.transcript %>
269
+ </div>
270
+ <% end %>
271
+ </div>
272
+ <% end %>
273
+ </div>
274
+ <% end %>
275
+ </div>
276
+ ```
277
+
278
+ ## Complete Example
279
+
280
+ ### Full Rails Integration
281
+
282
+ Here's a complete example showing how all the pieces work together:
283
+
284
+ #### 1. Database Migration
285
+
286
+ ```ruby
287
+ # db/migrate/xxx_create_conversation_transcripts.rb
288
+ class CreateConversationTranscripts < ActiveRecord::Migration[7.0]
289
+ def change
290
+ create_table :conversation_transcripts do |t|
291
+ t.string :conversation_id, null: false
292
+ t.text :transcript
293
+ t.text :user_message
294
+ t.text :assistant_message
295
+ t.datetime :timestamp
296
+ t.timestamps
297
+ end
298
+
299
+ add_index :conversation_transcripts, :conversation_id, unique: true
300
+ end
301
+ end
302
+ ```
303
+
304
+ #### 2. Model
305
+
306
+ ```ruby
307
+ # app/models/conversation_transcript.rb
308
+ class ConversationTranscript < ApplicationRecord
309
+ validates :conversation_id, presence: true, uniqueness: true
310
+ end
311
+ ```
312
+
313
+ #### 3. ElevenLabs Widget Integration
314
+
315
+ ```html
316
+ <!-- app/views/home/index.html.erb -->
317
+ <div id="voice-chat-container">
318
+ <!-- ElevenLabs Conversational AI Widget -->
319
+ <elevenlabs-convai agent-id="YOUR_AGENT_ID"></elevenlabs-convai>
320
+ </div>
321
+
322
+ <script src="https://unpkg.com/@elevenlabs/convai-widget-embed" async type="text/javascript"></script>
323
+ ```
324
+
325
+ ## Key Points
326
+
327
+ ### Webhook Best Practices
328
+
329
+ 1. **Always forward raw webhook data**: Use `request.raw_post` to preserve the original payload
330
+ 2. **Include headers**: ElevenLabs headers contain important metadata
331
+ 3. **Set source as "elevenlabs"**: This helps Coolhand properly categorize the data
332
+ 4. **Handle errors gracefully**: Log errors but always return 200 OK to prevent webhook retries
333
+
334
+ ### Feedback Matching
335
+
336
+ 1. **Use `llm_provider_unique_id`**: This field allows matching by the ElevenLabs conversation ID
337
+ 2. **Don't use `llm_request_log_id`** unless you have the actual Coolhand log ID
338
+ 3. **Feedback location**: Look for feedback in `metadata.feedback` in the API response
339
+
340
+ ### Data Structure
341
+
342
+ ElevenLabs feedback structure in API response:
343
+ ```json
344
+ {
345
+ "metadata": {
346
+ "feedback": {
347
+ "type": "rating",
348
+ "rating": 2,
349
+ "comment": "didn't work",
350
+ "likes": 0,
351
+ "dislikes": 0
352
+ }
353
+ }
354
+ }
355
+ ```
356
+
357
+ ### Environment Variables
358
+
359
+ Required environment variables:
360
+ ```bash
361
+ COOLHAND_API_KEY=your_coolhand_api_key
362
+ ELEVENLABS_API_KEY=your_elevenlabs_api_key
363
+ ```
364
+
365
+ ## Troubleshooting
366
+
367
+ ### Common Issues
368
+
369
+ 1. **Webhook not being received**
370
+ - Verify your webhook URL is publicly accessible
371
+ - Check ElevenLabs dashboard for webhook configuration
372
+ - Look for errors in Rails logs
373
+
374
+ 2. **Feedback not found**
375
+ - Ensure the user actually provided feedback in the widget
376
+ - Check that conversation ID is correct
377
+ - Verify API key has proper permissions
378
+
379
+ 3. **Coolhand submission fails**
380
+ - Use `llm_provider_unique_id` instead of `llm_request_log_id`
381
+ - Ensure Coolhand API key is valid
382
+ - Check that feedback data is properly formatted
383
+
384
+ ### Testing
385
+
386
+ Test the integration in Rails console:
387
+ ```ruby
388
+ # Test ElevenLabs API connection
389
+ service = ElevenlabsApiService.new
390
+ conversation_id = "conv_xxx"
391
+ response = service.fetch_conversation(conversation_id)
392
+ feedback = service.extract_feedback(response)
393
+
394
+ # Test Coolhand submission
395
+ coolhand_feedback = {
396
+ like: feedback[:feedback_rating],
397
+ explanation: feedback[:feedback_text],
398
+ llm_provider_unique_id: conversation_id
399
+ }
400
+ result = Coolhand.feedback_service.create_feedback(coolhand_feedback)
401
+ ```
402
+
403
+ ## Support
404
+
405
+ For issues specific to:
406
+ - **Coolhand Ruby Gem**: [GitHub Issues](https://github.com/your-org/coolhand-ruby)
407
+ - **ElevenLabs API**: [ElevenLabs Documentation](https://docs.elevenlabs.io)
408
+ - **Integration Questions**: Contact your Coolhand support team
@@ -3,6 +3,7 @@
3
3
  require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
+ require_relative "collector"
6
7
 
7
8
  module Coolhand
8
9
  module Ruby
@@ -29,6 +30,11 @@ module Coolhand
29
30
 
30
31
  protected
31
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
+
32
38
  def create_request_options(_payload)
33
39
  {
34
40
  "Content-Type" => "application/json",
@@ -43,8 +49,18 @@ module Coolhand
43
49
 
44
50
  request = Net::HTTP::Post.new(uri.request_uri)
45
51
  headers = create_request_options(payload)
46
- headers.each { |key, value| request[key] = value }
47
- request.body = JSON.generate(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")
48
64
 
49
65
  begin
50
66
  response = http.request(request)
@@ -54,11 +70,12 @@ module Coolhand
54
70
  log success_message
55
71
  result
56
72
  else
57
- puts "❌ Request failed: #{response.code} - #{response.body}"
73
+ body = response.body.force_encoding("UTF-8") if response.body
74
+ puts "❌ Request failed: #{response.code} - #{body}"
58
75
  nil
59
76
  end
60
77
  rescue StandardError => e
61
- puts "❌ Request error: #{e.message}"
78
+ log "❌ Request error: #{e.message}"
62
79
  nil
63
80
  end
64
81
  end
@@ -71,9 +88,11 @@ module Coolhand
71
88
  log("═" * 60) unless silent
72
89
  end
73
90
 
74
- def create_feedback(feedback)
91
+ def create_feedback(feedback, collection_method = nil)
92
+ feedback_with_collector = add_collector_to_data(feedback, collection_method)
93
+
75
94
  payload = {
76
- llm_request_log_feedback: feedback
95
+ llm_request_log_feedback: feedback_with_collector
77
96
  }
78
97
 
79
98
  log_feedback_info(feedback)
@@ -87,11 +106,11 @@ module Coolhand
87
106
  result
88
107
  end
89
108
 
90
- def create_log(captured_data)
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
+
91
112
  payload = {
92
- llm_request_log: {
93
- raw_request: captured_data
94
- }
113
+ llm_request_log: raw_request_with_collector
95
114
  }
96
115
 
97
116
  log_request_info(captured_data)
@@ -107,12 +126,68 @@ module Coolhand
107
126
  result
108
127
  end
109
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
+
110
150
  private
111
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
+
112
179
  def log_feedback_info(feedback)
113
180
  return if silent
114
181
 
115
- puts "\n📝 CREATING FEEDBACK for LLM Request Log ID: #{feedback[:llm_request_log_id]}"
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
+
116
191
  puts "👍/👎 Like: #{feedback[:like]}"
117
192
 
118
193
  if feedback[:explanation]
@@ -0,0 +1,21 @@
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
@@ -11,7 +11,7 @@ module Coolhand
11
11
  @environment = "production"
12
12
  @api_key = nil
13
13
  @silent = false
14
- @intercept_addresses = ["api.openai.com", "api.anthropic.com"]
14
+ @intercept_addresses = ["api.openai.com", "api.anthropic.com", "api.elevenlabs.io", ":generateContent"]
15
15
  end
16
16
 
17
17
  # Custom setter that preserves defaults when nil/empty array is provided
@@ -9,7 +9,9 @@ module Coolhand
9
9
  super("v2/llm_request_log_feedbacks")
10
10
  end
11
11
 
12
- public :create_feedback
12
+ def create_feedback(feedback)
13
+ super(feedback, "manual")
14
+ end
13
15
  end
14
16
  end
15
17
  end
@@ -10,7 +10,104 @@ module Coolhand
10
10
  end
11
11
 
12
12
  def log_to_api(captured_data)
13
- create_log(captured_data)
13
+ create_log(captured_data, "auto-monitor")
14
+ end
15
+
16
+ # Helper method for forwarding webhook data to Coolhand
17
+ def forward_webhook(webhook_body:, source:, event_type: nil, headers: {}, **options)
18
+ # Validate required parameters
19
+ unless Coolhand.required_field?(webhook_body)
20
+ error_msg = "webhook_body is required and cannot be nil or empty"
21
+ if Coolhand.configuration.silent
22
+ puts "COOLHAND WARNING: #{error_msg}"
23
+ return false
24
+ else
25
+ raise ArgumentError, error_msg
26
+ end
27
+ end
28
+
29
+ unless Coolhand.required_field?(source)
30
+ error_msg = "source is required and cannot be nil or empty"
31
+ if Coolhand.configuration.silent
32
+ puts "COOLHAND WARNING: #{error_msg}"
33
+ return false
34
+ else
35
+ raise ArgumentError, error_msg
36
+ end
37
+ end
38
+
39
+ # Auto-generate required fields unless provided
40
+ webhook_data = {
41
+ id: options[:id] || SecureRandom.uuid,
42
+ timestamp: options[:timestamp] || Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ"),
43
+ method: options[:method] || "POST",
44
+ url: options[:url] || build_webhook_url(source, event_type),
45
+ headers: sanitize_headers(headers),
46
+ request_body: clean_webhook_body(webhook_body, source),
47
+ response_body: options[:response_body],
48
+ response_headers: options[:response_headers],
49
+ status_code: options[:status_code] || 200,
50
+ source: "#{source}_webhook"
51
+ }.merge(options.slice(:metadata, :conversation_id, :agent_id))
52
+
53
+ # Send to API asynchronously
54
+ log_to_api(webhook_data)
55
+ end
56
+
57
+ private
58
+
59
+ def build_webhook_url(source, event_type)
60
+ base = "webhook://#{source}"
61
+ event_type ? "#{base}/#{event_type}" : base
62
+ end
63
+
64
+ def sanitize_headers(headers)
65
+ return {} if headers.nil?
66
+ return {} if headers.respond_to?(:empty?) && headers.empty?
67
+ return {} unless headers.respond_to?(:each)
68
+
69
+ # Handle Rails request headers or plain hash
70
+ clean_headers = {}
71
+ headers.each do |key, value|
72
+ next unless key && value
73
+
74
+ # Convert Rails HTTP_ prefix headers
75
+ clean_key = key.to_s.gsub(/^HTTP_/, "").tr("_", "-").downcase
76
+
77
+ # Redact sensitive headers
78
+ clean_value = clean_key.match?(/key|token|secret|authorization|signature/i) ? "[REDACTED]" : value.to_s
79
+ clean_headers[clean_key] = clean_value
80
+ end
81
+ clean_headers
82
+ end
83
+
84
+ def clean_webhook_body(body, source)
85
+ # Service-specific binary field filters
86
+ binary_fields = case source.to_s.downcase
87
+ when "elevenlabs"
88
+ %w[full_audio audio audio_data raw_audio audio_base64 voice_sample audio_url]
89
+ when "twilio"
90
+ %w[recording_url media_url]
91
+ else
92
+ %w[audio_data image_data file_content binary_content]
93
+ end
94
+
95
+ remove_binary_fields(body, binary_fields)
96
+ end
97
+
98
+ def remove_binary_fields(obj, fields_to_remove)
99
+ case obj
100
+ when Hash
101
+ obj.each_with_object({}) do |(key, value), cleaned|
102
+ next if fields_to_remove.any? { |field| key.to_s.downcase.include?(field) }
103
+
104
+ cleaned[key] = remove_binary_fields(value, fields_to_remove)
105
+ end
106
+ when Array
107
+ obj.map { |item| remove_binary_fields(item, fields_to_remove) }
108
+ else
109
+ obj
110
+ end
14
111
  end
15
112
  end
16
113
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Coolhand
4
4
  module Ruby
5
- VERSION = "0.1.2"
5
+ VERSION = "0.1.4"
6
6
  end
7
7
  end
data/lib/coolhand/ruby.rb CHANGED
@@ -7,6 +7,7 @@ require "securerandom"
7
7
 
8
8
  require_relative "ruby/version"
9
9
  require_relative "ruby/configuration"
10
+ require_relative "ruby/collector"
10
11
  require_relative "ruby/interceptor"
11
12
  require_relative "ruby/api_service"
12
13
  require_relative "ruby/logger_service"
@@ -77,5 +78,13 @@ module Coolhand
77
78
  def logger_service
78
79
  Ruby::LoggerService.new
79
80
  end
81
+
82
+ def required_field?(value)
83
+ return false if value.nil?
84
+ return false if value.respond_to?(:empty?) && value.empty?
85
+ return false if value.to_s.strip.empty?
86
+
87
+ true
88
+ end
80
89
  end
81
90
  end
data/lib/coolhand.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Main entry point for the Coolhand gem
4
+ require_relative "coolhand/ruby"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coolhand
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Carroll
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2025-10-22 00:00:00.000000000 Z
12
+ date: 2025-12-06 00:00:00.000000000 Z
13
13
  dependencies: []
14
14
  description: A Ruby gem to automatically monitor and log external LLM requests. It
15
15
  patches Net::HTTP to capture request and response data.
@@ -28,8 +28,11 @@ files:
28
28
  - README.md
29
29
  - Rakefile
30
30
  - coolhand-ruby.gemspec
31
+ - docs/elevenlabs.md
32
+ - lib/coolhand.rb
31
33
  - lib/coolhand/ruby.rb
32
34
  - lib/coolhand/ruby/api_service.rb
35
+ - lib/coolhand/ruby/collector.rb
33
36
  - lib/coolhand/ruby/configuration.rb
34
37
  - lib/coolhand/ruby/feedback_service.rb
35
38
  - lib/coolhand/ruby/interceptor.rb