boxcars 0.8.2 → 0.8.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: 842e02d2d8d43ea2a989c5a0b5708022443cf532d7616ab4319a063705f808fa
4
- data.tar.gz: 0c3349ae1dc50be9984bd440c520a74f2f972151338f1ff23aca5bc47d56837c
3
+ metadata.gz: 40c67f759e97f22570fba131b967c73b871b1f96cfbc97df78b1c0438bdf4291
4
+ data.tar.gz: 55f5e64fa79b8035e1bceb95912cbc5a8c587c29fa3b09d89a65ab83fe4375a6
5
5
  SHA512:
6
- metadata.gz: 68c3560e8e67c59f711c6433ba5950b48e5ecc0d955b039280399c81baf26361bb659eae89660f2180064b8a2ec7cf799bf0950b42cb5bc8c28b224fa6eb6c53
7
- data.tar.gz: 9f2538a7659b6ab4f236f23fbecb0a594f0ad0778e82b4fc634a2179e355fb40fb5f9ec54f6b9df6cda558a93ca5d2522c950ed8fe454d800cea1db16f5b72ba
6
+ metadata.gz: ba4b4434d263c9b4a875f5ab2db607d11a91795592f39c608143c058a38b773e6349ece90946f7a2137a6edd56d375ebd5992195921660ceac2fa5b4262e6ef5
7
+ data.tar.gz: c8d58cdfeb28d9a2d6bcba655478d7dd3572db4c2fbd72a566cca6a71c37941d0b7592c02c75f3430e8e7821dcf959942de2d960335864d70da1976e9f7ae63a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.8.2](https://github.com/BoxcarsAI/boxcars/tree/v0.8.2) (2025-06-04)
4
+
5
+ [Full Changelog](https://github.com/BoxcarsAI/boxcars/compare/v0.8.1...v0.8.2)
6
+
7
+ ## [v0.8.1](https://github.com/BoxcarsAI/boxcars/tree/v0.8.1) (2025-06-03)
8
+
9
+ [Full Changelog](https://github.com/BoxcarsAI/boxcars/compare/v0.8.0...v0.8.1)
10
+
3
11
  ## [v0.8.0](https://github.com/BoxcarsAI/boxcars/tree/v0.8.0) (2025-06-03)
4
12
 
5
13
  [Full Changelog](https://github.com/BoxcarsAI/boxcars/compare/v0.7.7...v0.8.0)
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- boxcars (0.8.2)
4
+ boxcars (0.8.4)
5
5
  faraday-retry (~> 2.0)
6
6
  google_search_results (~> 2.2)
7
7
  gpt4all (~> 0.0.5)
@@ -56,10 +56,10 @@ GEM
56
56
  async (>= 1.25)
57
57
  base64 (0.3.0)
58
58
  benchmark (0.4.1)
59
- bigdecimal (3.2.1)
59
+ bigdecimal (3.2.2)
60
60
  concurrent-ruby (1.3.5)
61
61
  connection_pool (2.5.3)
62
- console (1.30.2)
62
+ console (1.31.0)
63
63
  fiber-annotation
64
64
  fiber-local (~> 1.1)
65
65
  json
@@ -74,7 +74,7 @@ GEM
74
74
  domain_name (0.6.20240107)
75
75
  dotenv (3.1.8)
76
76
  drb (2.2.3)
77
- dynamicschema (1.0.0.beta04)
77
+ dynamicschema (1.0.0)
78
78
  erb (5.0.1)
79
79
  event_stream_parser (1.0.0)
80
80
  faraday (2.13.1)
@@ -132,7 +132,7 @@ GEM
132
132
  mime-types (3.7.0)
133
133
  logger
134
134
  mime-types-data (~> 3.2025, >= 3.2025.0507)
135
- mime-types-data (3.2025.0527)
135
+ mime-types-data (3.2025.0603)
136
136
  minitest (5.25.5)
137
137
  multi_json (1.15.0)
138
138
  multipart-post (2.4.1)
@@ -211,7 +211,7 @@ GEM
211
211
  diff-lcs (>= 1.2.0, < 2.0)
212
212
  rspec-support (~> 3.13.0)
213
213
  rspec-support (3.13.4)
214
- rubocop (1.75.8)
214
+ rubocop (1.76.1)
215
215
  json (~> 2.3)
216
216
  language_server-protocol (~> 3.17.0.2)
217
217
  lint_roller (~> 1.1.0)
@@ -219,10 +219,10 @@ GEM
219
219
  parser (>= 3.3.0.2)
220
220
  rainbow (>= 2.2.2, < 4.0)
221
221
  regexp_parser (>= 2.9.3, < 3.0)
222
- rubocop-ast (>= 1.44.0, < 2.0)
222
+ rubocop-ast (>= 1.45.0, < 2.0)
223
223
  ruby-progressbar (~> 1.7)
224
224
  unicode-display_width (>= 2.4.0, < 4.0)
225
- rubocop-ast (1.44.1)
225
+ rubocop-ast (1.45.1)
226
226
  parser (>= 3.3.7.2)
227
227
  prism (~> 1.4)
228
228
  rubocop-rake (0.6.0)
@@ -243,14 +243,14 @@ GEM
243
243
  addressable (>= 2.3.5)
244
244
  faraday (>= 0.17.3, < 3)
245
245
  securerandom (0.4.1)
246
- sqlite3 (2.6.0-aarch64-linux-gnu)
247
- sqlite3 (2.6.0-aarch64-linux-musl)
248
- sqlite3 (2.6.0-arm-linux-gnu)
249
- sqlite3 (2.6.0-arm-linux-musl)
250
- sqlite3 (2.6.0-arm64-darwin)
251
- sqlite3 (2.6.0-x86_64-darwin)
252
- sqlite3 (2.6.0-x86_64-linux-gnu)
253
- sqlite3 (2.6.0-x86_64-linux-musl)
246
+ sqlite3 (2.7.0-aarch64-linux-gnu)
247
+ sqlite3 (2.7.0-aarch64-linux-musl)
248
+ sqlite3 (2.7.0-arm-linux-gnu)
249
+ sqlite3 (2.7.0-arm-linux-musl)
250
+ sqlite3 (2.7.0-arm64-darwin)
251
+ sqlite3 (2.7.0-x86_64-darwin)
252
+ sqlite3 (2.7.0-x86_64-linux-gnu)
253
+ sqlite3 (2.7.0-x86_64-linux-musl)
254
254
  stringio (3.1.7)
255
255
  strings-ansi (0.2.0)
256
256
  timeout (0.4.3)
data/README.md CHANGED
@@ -388,7 +388,7 @@ Boxcars automatically tracks LLM calls with detailed metrics:
388
388
 
389
389
  ```ruby
390
390
  # This automatically generates observability events
391
- engine = Boxcars::Openai.new
391
+ engine = Boxcars::Openai.new(user_id: USER_ID) # optional user_id. All engines take this.
392
392
  calc = Boxcars::Calculator.new(engine: engine)
393
393
  result = calc.run "what is 2 + 2?"
394
394
  ```
@@ -404,6 +404,7 @@ result = calc.run "what is 2 + 2?"
404
404
  - `error_message`: Error details if the call failed
405
405
  - `response_raw_body`: Raw API response
406
406
  - `api_call_parameters`: Parameters sent to the API
407
+ - `distinct_id`: If you specify a user_id to your engine, it will be passed up.
407
408
 
408
409
  #### Manual Tracking
409
410
 
@@ -0,0 +1,435 @@
1
+ # User Context in Boxcars Observations
2
+
3
+ This guide explains how to use the new user context feature in Boxcars observations to track which user performed specific actions.
4
+
5
+ ## Overview
6
+
7
+ The user context feature allows you to associate user information with Boxcars observations, enabling better tracking, debugging, and analytics. This is particularly useful when using Boxcars in web applications where you want to tie AI operations to specific users.
8
+
9
+ ## Key Features
10
+
11
+ - **Non-intrusive**: Uses the existing `added_context` mechanism
12
+ - **Backward compatible**: Existing code continues to work unchanged
13
+ - **Flexible**: You control what user data is included
14
+ - **Analytics-ready**: Automatically formats user data for analytics systems like PostHog
15
+ - **Privacy-conscious**: Only includes data you explicitly provide
16
+
17
+ ## Enhanced Observation Class
18
+
19
+ ### New Methods
20
+
21
+ #### `Observation.with_user(note, user_context:, status: :ok, **additional_context)`
22
+
23
+ Creates an observation with user context.
24
+
25
+ ```ruby
26
+ user_context = {
27
+ id: current_user.id,
28
+ email: current_user.email,
29
+ role: current_user.role
30
+ }
31
+
32
+ observation = Boxcars::Observation.with_user(
33
+ "User performed search",
34
+ user_context: user_context,
35
+ query: "machine learning",
36
+ results_count: 42
37
+ )
38
+ ```
39
+
40
+ #### `Observation.ok_with_user(note, user_context:, **additional_context)`
41
+
42
+ Creates a successful observation with user context.
43
+
44
+ ```ruby
45
+ observation = Boxcars::Observation.ok_with_user(
46
+ "Search completed successfully",
47
+ user_context: user_context,
48
+ processing_time_ms: 150
49
+ )
50
+ ```
51
+
52
+ #### `Observation.err_with_user(note, user_context:, **additional_context)`
53
+
54
+ Creates an error observation with user context.
55
+
56
+ ```ruby
57
+ observation = Boxcars::Observation.err_with_user(
58
+ "Search failed due to timeout",
59
+ user_context: user_context,
60
+ error_code: "TIMEOUT"
61
+ )
62
+ ```
63
+
64
+ ### New Instance Methods
65
+
66
+ #### `#user_context`
67
+
68
+ Returns the user context hash if present, `nil` otherwise.
69
+
70
+ ```ruby
71
+ observation.user_context
72
+ # => {id: 123, email: "user@example.com", role: "admin"}
73
+ ```
74
+
75
+ #### `#user_context?`
76
+
77
+ Returns `true` if the observation has user context, `false` otherwise.
78
+
79
+ ```ruby
80
+ observation.user_context?
81
+ # => true
82
+ ```
83
+
84
+ ## Enhanced Observability System
85
+
86
+ ### Updated Track Method
87
+
88
+ The `Boxcars::Observability.track` method now accepts an optional `observation` parameter:
89
+
90
+ ```ruby
91
+ Boxcars::Observability.track(
92
+ event: 'user_action',
93
+ properties: { action_type: 'search' },
94
+ observation: observation_with_user_context
95
+ )
96
+ ```
97
+
98
+ When an observation with user context is provided, the user data is automatically merged into the tracking properties with `$user_` prefixes (PostHog compatible).
99
+
100
+ ### New Track Observation Method
101
+
102
+ ```ruby
103
+ Boxcars::Observability.track_observation(
104
+ observation,
105
+ event: 'custom_event_name', # optional, defaults to 'boxcar_observation'
106
+ additional_property: 'value'
107
+ )
108
+ ```
109
+
110
+ This method automatically:
111
+ - Extracts observation details (note, status, timestamp)
112
+ - Includes all additional context from the observation
113
+ - Merges user context with `$user_` prefixes
114
+ - Sends everything to your configured observability backend
115
+
116
+ ## Rails Integration Pattern
117
+
118
+ ### Controller Helper Methods
119
+
120
+ Create helper methods in your `ApplicationController`:
121
+
122
+ ```ruby
123
+ class ApplicationController < ActionController::Base
124
+ private
125
+
126
+ # Helper method to create observations with user context
127
+ def create_observation_with_user(note, status: :ok, **additional_context)
128
+ if current_user
129
+ Boxcars::Observation.with_user(
130
+ note,
131
+ user_context: current_user.to_user_context,
132
+ status: status,
133
+ **additional_context
134
+ )
135
+ else
136
+ Boxcars::Observation.new(note: note, status: status, **additional_context)
137
+ end
138
+ end
139
+
140
+ # Helper method for successful operations
141
+ def success_observation_with_user(note, **additional_context)
142
+ create_observation_with_user(note, status: :ok, **additional_context)
143
+ end
144
+
145
+ # Helper method for error operations
146
+ def error_observation_with_user(note, **additional_context)
147
+ create_observation_with_user(note, status: :error, **additional_context)
148
+ end
149
+ end
150
+ ```
151
+
152
+ ### User Model Extension
153
+
154
+ Add a method to your User model to convert user data to context:
155
+
156
+ ```ruby
157
+ class User < ApplicationRecord
158
+ def to_user_context
159
+ {
160
+ id: id,
161
+ email: email,
162
+ role: role,
163
+ # Add other relevant fields, but be mindful of privacy
164
+ # Don't include sensitive data like passwords, tokens, etc.
165
+ }
166
+ end
167
+ end
168
+ ```
169
+
170
+ ### Usage in Controllers
171
+
172
+ ```ruby
173
+ class SearchController < ApplicationController
174
+ def search
175
+ query = params[:query]
176
+
177
+ begin
178
+ # Perform search with Boxcars
179
+ results = perform_boxcar_search(query)
180
+
181
+ # Track successful search
182
+ observation = success_observation_with_user(
183
+ "Search completed successfully",
184
+ query: query,
185
+ results_count: results.count,
186
+ processing_time_ms: 250
187
+ )
188
+
189
+ Boxcars::Observability.track_observation(
190
+ observation,
191
+ event: 'search_completed',
192
+ controller: 'SearchController',
193
+ action: 'search'
194
+ )
195
+
196
+ render json: { results: results }
197
+
198
+ rescue StandardError => e
199
+ # Track search error
200
+ error_observation = error_observation_with_user(
201
+ "Search failed: #{e.message}",
202
+ query: query,
203
+ error_class: e.class.name
204
+ )
205
+
206
+ Boxcars::Observability.track_observation(
207
+ error_observation,
208
+ event: 'search_failed',
209
+ controller: 'SearchController',
210
+ action: 'search'
211
+ )
212
+
213
+ render json: { error: "Search failed" }, status: 500
214
+ end
215
+ end
216
+ end
217
+ ```
218
+
219
+ ## Analytics Integration
220
+
221
+ ### PostHog Integration
222
+
223
+ When using PostHog as your observability backend, user context is automatically formatted with `$user_` prefixes:
224
+
225
+ ```ruby
226
+ # This user context:
227
+ user_context = {
228
+ id: 123,
229
+ email: "user@example.com",
230
+ role: "admin"
231
+ }
232
+
233
+ # Becomes these PostHog properties:
234
+ {
235
+ "$user_id" => 123,
236
+ "$user_email" => "user@example.com",
237
+ "$user_role" => "admin"
238
+ }
239
+ ```
240
+
241
+ This allows PostHog to automatically associate events with users and enables user-based analytics and segmentation.
242
+
243
+ ### Custom Analytics Systems
244
+
245
+ For other analytics systems, you can access the user context directly:
246
+
247
+ ```ruby
248
+ observation = Boxcars::Observation.ok_with_user("Action completed", user_context: user_data)
249
+
250
+ # Access user context
251
+ user_info = observation.user_context
252
+ # => {id: 123, email: "user@example.com", role: "admin"}
253
+
254
+ # Include in your custom tracking
255
+ your_analytics.track(
256
+ event: 'boxcar_action',
257
+ user_id: user_info[:id],
258
+ user_email: user_info[:email],
259
+ properties: observation.to_h
260
+ )
261
+ ```
262
+
263
+ ## Privacy Considerations
264
+
265
+ ### What to Include
266
+
267
+ **Safe to include:**
268
+ - User ID (for tracking purposes)
269
+ - Email (if needed for support)
270
+ - Role/permissions level
271
+ - Account type (free, premium, etc.)
272
+ - Tenant/organization ID
273
+
274
+ **Avoid including:**
275
+ - Passwords or password hashes
276
+ - API keys or tokens
277
+ - Personal identification numbers
278
+ - Credit card information
279
+ - Any other sensitive personal data
280
+
281
+ ### Example Safe User Context
282
+
283
+ ```ruby
284
+ def to_user_context
285
+ {
286
+ id: id,
287
+ email: email,
288
+ role: role,
289
+ account_type: subscription&.plan_name,
290
+ organization_id: organization_id,
291
+ created_at: created_at.iso8601,
292
+ last_login: last_sign_in_at&.iso8601
293
+ }
294
+ end
295
+ ```
296
+
297
+ ## Testing
298
+
299
+ ### RSpec Examples
300
+
301
+ ```ruby
302
+ RSpec.describe "User Context in Observations" do
303
+ let(:user_context) do
304
+ {
305
+ id: 123,
306
+ email: "test@example.com",
307
+ role: "admin"
308
+ }
309
+ end
310
+
311
+ it "creates observation with user context" do
312
+ observation = Boxcars::Observation.ok_with_user(
313
+ "Test action",
314
+ user_context: user_context
315
+ )
316
+
317
+ expect(observation.user_context?).to be true
318
+ expect(observation.user_context[:id]).to eq(123)
319
+ expect(observation.user_context[:email]).to eq("test@example.com")
320
+ end
321
+
322
+ it "tracks observation with user context" do
323
+ observation = Boxcars::Observation.ok_with_user(
324
+ "Test action",
325
+ user_context: user_context
326
+ )
327
+
328
+ expect(Boxcars::Observability).to receive(:track).with(
329
+ event: 'test_event',
330
+ properties: hash_including(
331
+ "$user_id" => 123,
332
+ "$user_email" => "test@example.com"
333
+ )
334
+ )
335
+
336
+ Boxcars::Observability.track(
337
+ event: 'test_event',
338
+ properties: {},
339
+ observation: observation
340
+ )
341
+ end
342
+ end
343
+ ```
344
+
345
+ ## Migration Guide
346
+
347
+ ### Existing Code
348
+
349
+ Your existing observation code continues to work unchanged:
350
+
351
+ ```ruby
352
+ # This still works exactly as before
353
+ observation = Boxcars::Observation.ok("Action completed", extra: "data")
354
+ Boxcars::Observability.track(event: 'action', properties: { type: 'test' })
355
+ ```
356
+
357
+ ### Adding User Context
358
+
359
+ To add user context to existing observations, simply replace:
360
+
361
+ ```ruby
362
+ # Before
363
+ observation = Boxcars::Observation.ok("Action completed")
364
+
365
+ # After
366
+ observation = Boxcars::Observation.ok_with_user(
367
+ "Action completed",
368
+ user_context: current_user.to_user_context
369
+ )
370
+ ```
371
+
372
+ ### Updating Tracking Calls
373
+
374
+ To include user context in tracking:
375
+
376
+ ```ruby
377
+ # Before
378
+ Boxcars::Observability.track(
379
+ event: 'action_completed',
380
+ properties: { action_type: 'search' }
381
+ )
382
+
383
+ # After
384
+ Boxcars::Observability.track_observation(
385
+ observation_with_user_context,
386
+ event: 'action_completed',
387
+ action_type: 'search'
388
+ )
389
+ ```
390
+
391
+ ## Benefits
392
+
393
+ 1. **Better Debugging**: Quickly identify which user encountered an issue
394
+ 2. **User Analytics**: Segment usage patterns by user type, role, or other attributes
395
+ 3. **Compliance**: Track user actions for audit trails
396
+ 4. **Support**: Faster issue resolution with user context
397
+ 5. **Product Insights**: Understand how different user segments use AI features
398
+
399
+ ## Best Practices
400
+
401
+ 1. **Consistent Context**: Use the same user context structure across your application
402
+ 2. **Helper Methods**: Create controller helpers to reduce code duplication
403
+ 3. **Privacy First**: Only include necessary user data
404
+ 4. **Error Handling**: Always handle cases where `current_user` might be nil
405
+ 5. **Testing**: Write tests for both user and anonymous scenarios
406
+ 6. **Documentation**: Document what user data you're tracking and why
407
+
408
+ ## Troubleshooting
409
+
410
+ ### Common Issues
411
+
412
+ **User context not appearing in analytics:**
413
+ - Ensure your observability backend is configured
414
+ - Check that you're using `track_observation` or passing the `observation` parameter to `track`
415
+ - Verify the observation actually has user context with `user_context?`
416
+
417
+ **Anonymous users causing errors:**
418
+ - Always check for `current_user` presence before creating user context
419
+ - Use helper methods that handle nil users gracefully
420
+
421
+ **Missing user data:**
422
+ - Ensure your `to_user_context` method returns a hash
423
+ - Check that the user object has the expected attributes
424
+
425
+ ### Debug Example
426
+
427
+ ```ruby
428
+ # Debug user context
429
+ observation = Boxcars::Observation.ok_with_user("Test", user_context: user_data)
430
+ puts "Has user context: #{observation.user_context?}"
431
+ puts "User context: #{observation.user_context.inspect}"
432
+ puts "Full observation: #{observation.to_h.inspect}"
433
+ ```
434
+
435
+ This comprehensive user context system provides a clean, privacy-conscious way to associate user information with Boxcars operations, enabling better tracking, debugging, and analytics while maintaining backward compatibility.
@@ -28,10 +28,11 @@ module Boxcars
28
28
  # useful for when you need to use AI to answer questions. You should ask targeted questions".
29
29
  # @param prompts [Array<String>] The prompts to use when asking the engine. Defaults to [].
30
30
  def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], **kwargs)
31
+ user_id = kwargs.delete(:user_id)
31
32
  @llm_params = DEFAULT_PARAMS.merge(kwargs)
32
33
  @prompts = prompts
33
34
  @batch_size = 20
34
- super(description:, name:)
35
+ super(description:, name:, user_id:)
35
36
  end
36
37
 
37
38
  def conversation_model?(_model)
@@ -278,7 +279,8 @@ module Boxcars
278
279
  request_context = {
279
280
  prompt: call_context[:prompt_object],
280
281
  inputs: call_context[:inputs],
281
- conversation_for_api: call_context[:api_request_params]
282
+ conversation_for_api: call_context[:api_request_params],
283
+ user_id:
282
284
  }
283
285
 
284
286
  track_ai_generation(
@@ -27,10 +27,11 @@ module Boxcars
27
27
  # useful for when you need to use AI to answer questions. You should ask targeted questions".
28
28
  # @param prompts [Array<String>] The prompts to use when asking the engine. Defaults to [].
29
29
  def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], **kwargs)
30
+ user_id = kwargs.delete(:user_id)
30
31
  @llm_params = DEFAULT_PARAMS.merge(kwargs)
31
32
  @prompts = prompts
32
33
  @batch_size = 20
33
- super(description:, name:)
34
+ super(description:, name:, user_id:)
34
35
  end
35
36
 
36
37
  def conversation_model?(_model)
@@ -211,7 +212,8 @@ module Boxcars
211
212
  request_context = {
212
213
  prompt: call_context[:prompt_object],
213
214
  inputs: call_context[:inputs],
214
- conversation_for_api: call_context[:api_request_params]
215
+ conversation_for_api: call_context[:api_request_params],
216
+ user_id:
215
217
  }
216
218
 
217
219
  track_ai_generation(
@@ -19,10 +19,11 @@ module Boxcars
19
19
  "You should ask targeted questions"
20
20
 
21
21
  def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 20, **kwargs)
22
+ user_id = kwargs.delete(:user_id)
22
23
  @llm_params = DEFAULT_PARAMS.merge(kwargs) # Corrected typo here
23
24
  @prompts = prompts
24
25
  @batch_size = batch_size
25
- super(description:, name:)
26
+ super(description:, name:, user_id:)
26
27
  end
27
28
 
28
29
  # Renamed from open_ai_client to gemini_client for clarity
@@ -68,7 +69,8 @@ module Boxcars
68
69
  request_context = {
69
70
  prompt: current_prompt_object,
70
71
  inputs:,
71
- conversation_for_api: api_request_params&.dig(:messages) || []
72
+ conversation_for_api: api_request_params&.dig(:messages) || [],
73
+ user_id:
72
74
  }
73
75
  track_ai_generation(
74
76
  duration_ms:,
@@ -79,12 +81,16 @@ module Boxcars
79
81
  )
80
82
  end
81
83
 
82
- _gemini_handle_call_outcome(response_data:)
84
+ # If there's an error, raise it to maintain backward compatibility with existing tests
85
+ raise response_data[:error] if response_data[:error]
86
+
87
+ response_data[:parsed_json]
83
88
  end
84
89
 
85
90
  def run(question, **)
86
91
  prompt = Prompt.new(template: question)
87
- answer = client(prompt:, inputs: {}, **)
92
+ response = client(prompt:, inputs: {}, **)
93
+ answer = _extract_content_from_gemini_response(response)
88
94
  Boxcars.debug("Answer: #{answer}", :cyan)
89
95
  answer
90
96
  end
@@ -19,10 +19,11 @@ module Boxcars
19
19
  }.freeze
20
20
 
21
21
  def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 2, **kwargs)
22
+ user_id = kwargs.delete(:user_id)
22
23
  @gpt4all_params = DEFAULT_PARAMS.merge(kwargs) # Store merged params
23
24
  @prompts = prompts
24
25
  @batch_size = batch_size # Retain if used by other methods
25
- super(description:, name:)
26
+ super(description:, name:, user_id:)
26
27
  end
27
28
 
28
29
  def client(prompt:, inputs: {}, **kwargs)
@@ -31,10 +32,8 @@ module Boxcars
31
32
  # current_params are the effective parameters for this call, including defaults and overrides
32
33
  current_params = @gpt4all_params.merge(kwargs)
33
34
  # api_request_params for GPT4All is just the input text.
34
- api_request_params = nil
35
+ api_request_params, gpt4all_instance = nil
35
36
  current_prompt_object = prompt.is_a?(Array) ? prompt.first : prompt
36
- gpt4all_instance = nil # To ensure it's in scope for ensure block
37
-
38
37
  begin
39
38
  gpt4all_instance = Gpt4all::ConversationalAI.new
40
39
  # prepare_resources might download models, could take time.
@@ -68,7 +67,8 @@ module Boxcars
68
67
  request_context = {
69
68
  prompt: current_prompt_object,
70
69
  inputs:,
71
- conversation_for_api: api_request_params&.dig(:prompt) # The text prompt
70
+ conversation_for_api: api_request_params&.dig(:prompt),
71
+ user_id:
72
72
  }
73
73
 
74
74
  track_ai_generation(
@@ -19,10 +19,11 @@ module Boxcars
19
19
  "You should ask targeted questions"
20
20
 
21
21
  def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 20, **kwargs)
22
+ user_id = kwargs.delete(:user_id)
22
23
  @groq_params = DEFAULT_PARAMS.merge(kwargs) # Corrected typo here
23
24
  @prompts = prompts
24
25
  @batch_size = batch_size
25
- super(description:, name:)
26
+ super(description:, name:, user_id:)
26
27
  end
27
28
 
28
29
  # Renamed from open_ai_client to groq_client for clarity
@@ -60,7 +61,8 @@ module Boxcars
60
61
  request_context = {
61
62
  prompt: current_prompt_object,
62
63
  inputs:,
63
- conversation_for_api: api_request_params&.dig(:messages)
64
+ conversation_for_api: api_request_params&.dig(:messages),
65
+ user_id:
64
66
  }
65
67
  track_ai_generation(
66
68
  duration_ms:,
@@ -71,22 +73,26 @@ module Boxcars
71
73
  )
72
74
  end
73
75
 
74
- _groq_handle_call_outcome(response_data:)
76
+ # If there's an error, raise it to maintain backward compatibility with existing tests
77
+ raise response_data[:error] if response_data[:error]
78
+
79
+ response_data[:parsed_json]
75
80
  end
76
81
 
77
82
  def run(question, **)
78
83
  prompt = Prompt.new(template: question)
79
- answer = client(prompt:, inputs: {}, **)
84
+ response = client(prompt:, inputs: {}, **)
85
+ answer = extract_answer(response)
80
86
  Boxcars.debug("Answer: #{answer}", :cyan)
81
87
  answer
82
88
  end
83
89
 
90
+ private
91
+
84
92
  def default_params
85
- @groq_params # Use instance variable
93
+ @groq_params
86
94
  end
87
95
 
88
- private
89
-
90
96
  # Helper methods for the client method
91
97
  def _prepare_groq_request_params(prompt_object, inputs, current_params)
92
98
  messages_hash_from_prompt = prompt_object.as_messages(inputs)
@@ -17,10 +17,11 @@ module Boxcars
17
17
  # @param batch_size [Integer] The number of prompts to send to the Engine at a time.
18
18
  # @param kwargs [Hash] Additional parameters to pass to the Engine.
19
19
  def initialize(provider:, description:, name:, prompts: [], batch_size: 20, **kwargs)
20
+ user_id = kwargs.delete(:user_id)
20
21
  @provider = provider
21
22
  # Start with defaults, merge other kwargs, then explicitly set model if provided in initialize
22
23
  @all_params = default_model_params.merge(kwargs)
23
- super(description:, name:, prompts:, batch_size:)
24
+ super(description:, name:, prompts:, batch_size:, user_id:)
24
25
  end
25
26
 
26
27
  # can be overridden by provider subclass
@@ -68,7 +69,7 @@ module Boxcars
68
69
 
69
70
  adapter = adapter(api_key:, params:)
70
71
  convo = prompt.as_intelligence_conversation(inputs:)
71
- request_context = { prompt: prompt&.as_prompt(inputs:)&.[](:prompt), inputs:, conversation_for_api: convo.to_h }
72
+ request_context = { user_id:, prompt: prompt&.as_prompt(inputs:)&.[](:prompt), inputs:, conversation_for_api: convo.to_h }
72
73
  request = Intelligence::ChatRequest.new(adapter:)
73
74
 
74
75
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -118,17 +119,6 @@ module Boxcars
118
119
 
119
120
  private
120
121
 
121
- def extract_answer(response)
122
- # Handle different response formats
123
- if response["choices"]
124
- response["choices"].map { |c| c.dig("message", "content") || c["text"] }.join("\n").strip
125
- elsif response["candidates"]
126
- response["candidates"].map { |c| c.dig("content", "parts", 0, "text") }.join("\n").strip
127
- else
128
- response["output"] || response.to_s
129
- end
130
- end
131
-
132
122
  def check_response(response)
133
123
  return if response.is_a?(Hash) && response.key?("choices")
134
124
 
@@ -19,10 +19,11 @@ module Boxcars
19
19
  "You should ask targeted questions"
20
20
 
21
21
  def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 2, **kwargs)
22
+ user_id = kwargs.delete(:user_id)
22
23
  @ollama_params = DEFAULT_PARAMS.merge(kwargs)
23
24
  @prompts = prompts
24
25
  @batch_size = batch_size # Retain if used by other methods
25
- super(description:, name:)
26
+ super(description:, name:, user_id:)
26
27
  end
27
28
 
28
29
  # Renamed from open_ai_client to ollama_client for clarity
@@ -63,7 +64,8 @@ module Boxcars
63
64
  request_context = {
64
65
  prompt: current_prompt_object,
65
66
  inputs:,
66
- conversation_for_api: api_request_params&.dig(:messages)
67
+ conversation_for_api: api_request_params&.dig(:messages),
68
+ user_id:
67
69
  }
68
70
  track_ai_generation(
69
71
  duration_ms:,
@@ -20,6 +20,7 @@ module Boxcars
20
20
  "You should ask targeted questions"
21
21
 
22
22
  def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 20, **kwargs)
23
+ user_id = kwargs.delete(:user_id)
23
24
  @open_ai_params = DEFAULT_PARAMS.merge(kwargs)
24
25
  # Special handling for o1-mini model (deprecated?)
25
26
  if @open_ai_params[:model] =~ /^o/ && @open_ai_params[:max_tokens]
@@ -29,7 +30,7 @@ module Boxcars
29
30
 
30
31
  @prompts = prompts
31
32
  @batch_size = batch_size
32
- super(description:, name:)
33
+ super(description:, name:, user_id:)
33
34
  end
34
35
 
35
36
  def self.open_ai_client(openai_access_token: nil)
@@ -128,6 +129,7 @@ module Boxcars
128
129
  # Called by Engine#generate to check the response from the client.
129
130
  # @param response [Hash] The parsed JSON response from the OpenAI API.
130
131
  # @raise [Boxcars::Error] if the response contains an error.
132
+ # rubocop:disable Naming/PredicateMethod
131
133
  def check_response(response)
132
134
  if response.is_a?(Hash) && response["error"]
133
135
  err_details = response["error"]
@@ -136,6 +138,7 @@ module Boxcars
136
138
  end
137
139
  true
138
140
  end
141
+ # rubocop:enable Naming/PredicateMethod
139
142
 
140
143
  def run(question, **)
141
144
  prompt = Prompt.new(template: question)
@@ -229,6 +232,7 @@ module Boxcars
229
232
  request_context = {
230
233
  prompt: call_context[:prompt_object],
231
234
  inputs: call_context[:inputs],
235
+ user_id:,
232
236
  conversation_for_api: is_chat_model ? api_request_params[:messages] : api_request_params[:prompt]
233
237
  }
234
238
 
@@ -20,10 +20,11 @@ module Boxcars
20
20
  "You should ask targeted questions"
21
21
 
22
22
  def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 20, **kwargs)
23
+ user_id = kwargs.delete(:user_id)
23
24
  @perplexity_params = DEFAULT_PARAMS.merge(kwargs)
24
25
  @prompts = prompts
25
26
  @batch_size = batch_size # Retain if used by generate
26
- super(description:, name:)
27
+ super(description:, name:, user_id:)
27
28
  end
28
29
 
29
30
  # Perplexity models are conversational.
@@ -96,6 +97,7 @@ module Boxcars
96
97
  request_context = {
97
98
  prompt: current_prompt_object,
98
99
  inputs:,
100
+ user_id:,
99
101
  conversation_for_api: api_request_params&.dig(:messages)
100
102
  }
101
103
  track_ai_generation(
@@ -52,7 +52,8 @@ module Boxcars
52
52
  '$ai_latency': duration_seconds,
53
53
  '$ai_http_status': extract_status_code(response_data) || (response_data[:success] ? 200 : 500),
54
54
  '$ai_base_url': get_base_url_for_provider(provider),
55
- '$ai_is_error': !response_data[:success]
55
+ '$ai_is_error': !response_data[:success],
56
+ user_id:
56
57
  }
57
58
 
58
59
  # Add error details if present
@@ -3,18 +3,20 @@
3
3
  module Boxcars
4
4
  # @abstract
5
5
  class Engine
6
- attr_reader :prompts, :batch_size
6
+ attr_reader :prompts, :batch_size, :user_id
7
7
 
8
8
  # An Engine is used by Boxcars to generate output from prompts
9
9
  # @param name [String] The name of the Engine. Defaults to classname.
10
10
  # @param description [String] A description of the Engine.
11
11
  # @param prompts [Array<Prompt>] The prompts to use for the Engine.
12
12
  # @param batch_size [Integer] The number of prompts to send to the Engine at a time.
13
- def initialize(description: 'Engine', name: nil, prompts: [], batch_size: 20)
13
+ # @param user_id [String, Integer] The ID of the user using this Engine (optional for observability).
14
+ def initialize(description: 'Engine', name: nil, prompts: [], batch_size: 20, user_id: nil)
14
15
  @name = name || self.class.name
15
16
  @description = description
16
17
  @prompts = prompts
17
18
  @batch_size = batch_size
19
+ @user_id = user_id
18
20
  end
19
21
 
20
22
  # Get an answer from the Engine.
@@ -57,12 +59,33 @@ module Boxcars
57
59
  # Includes prompt, completion, and total tokens used.
58
60
  inkeys = %w[completion_tokens prompt_tokens total_tokens].freeze
59
61
  prompts.each_slice(batch_size) do |sub_prompts|
60
- sub_prompts.each do |sprompts, inputs|
61
- response = client(prompt: sprompts, inputs:, **params)
62
- check_response(response)
63
- choices.concat(response["choices"])
64
- usage_keys = inkeys & response["usage"].keys
65
- usage_keys.each { |key| token_usage[key] = token_usage[key].to_i + response["usage"][key] }
62
+ sub_prompts.each do |sprompt, inputs|
63
+ client_response = client(prompt: sprompt, inputs:, **params)
64
+
65
+ # All engines now return the parsed API response hash directly
66
+ api_response_hash = client_response
67
+
68
+ # Ensure we have a hash to work with
69
+ unless api_response_hash.is_a?(Hash)
70
+ raise TypeError, "Expected Hash from client method, got #{api_response_hash.class}: #{api_response_hash.inspect}"
71
+ end
72
+
73
+ check_response(api_response_hash)
74
+
75
+ current_choices = api_response_hash["choices"]
76
+ if current_choices.is_a?(Array)
77
+ choices.concat(current_choices)
78
+ else
79
+ Boxcars.logger&.warn "No 'choices' found in API response: #{api_response_hash.inspect}"
80
+ end
81
+
82
+ api_usage = api_response_hash["usage"]
83
+ if api_usage.is_a?(Hash)
84
+ usage_keys = inkeys & api_usage.keys
85
+ usage_keys.each { |key| token_usage[key] = token_usage[key].to_i + api_usage[key] }
86
+ else
87
+ Boxcars.logger&.warn "No 'usage' data found in API response: #{api_response_hash.inspect}"
88
+ end
66
89
  end
67
90
  end
68
91
 
@@ -74,6 +97,17 @@ module Boxcars
74
97
  end
75
98
  EngineResult.new(generations:, engine_output: { token_usage: })
76
99
  end
100
+
101
+ def extract_answer(response)
102
+ # Handle different response formats
103
+ if response["choices"]
104
+ response["choices"].map { |c| c.dig("message", "content") || c["text"] }.join("\n").strip
105
+ elsif response["candidates"]
106
+ response["candidates"].map { |c| c.dig("content", "parts", 0, "text") }.join("\n").strip
107
+ else
108
+ response["output"] || response.to_s
109
+ end
110
+ end
77
111
  end
78
112
  end
79
113
 
@@ -20,16 +20,58 @@ module Boxcars
20
20
  #
21
21
  # @param event [String, Symbol] The name of the event to track.
22
22
  # @param properties [Hash] A hash of properties associated with the event.
23
- def track(event:, properties:)
23
+ # @param observation [Boxcars::Observation, nil] Optional observation object to extract user context from.
24
+ def track(event:, properties:, observation: nil)
24
25
  return unless backend
25
26
 
26
- backend.track(event:, properties:)
27
+ # Merge user context from observation if present
28
+ final_properties = properties.dup
29
+ final_properties = merge_user_context(final_properties, observation.user_context) if observation&.user_context?
30
+
31
+ backend.track(event:, properties: final_properties)
27
32
  rescue StandardError
28
33
  # Fail silently as requested.
29
34
  # Optionally, if Boxcars had a central logger:
30
35
  # Boxcars.logger.warn "Boxcars::Observability: Backend error during track: #{e.message} (#{e.class.name})"
31
36
  end
32
37
 
38
+ # Tracks an observation event, automatically extracting user context if present
39
+ # @param observation [Boxcars::Observation] The observation to track
40
+ # @param event [String, Symbol] The event name (defaults to 'boxcar_observation')
41
+ # @param additional_properties [Hash] Additional properties to include
42
+ def track_observation(observation, event: 'boxcar_observation', **additional_properties)
43
+ properties = {
44
+ observation_note: observation.note,
45
+ observation_status: observation.status,
46
+ timestamp: Time.now.iso8601
47
+ }.merge(additional_properties)
48
+
49
+ # Add all observation context (including user_context) to properties
50
+ properties.merge!(observation.added_context) if observation.added_context
51
+
52
+ track(event:, properties:, observation:)
53
+ end
54
+
55
+ private
56
+
57
+ # Merge user context into properties with proper namespacing
58
+ # @param properties [Hash] The existing properties
59
+ # @param user_context [Hash] The user context to merge
60
+ # @return [Hash] The merged properties
61
+ def merge_user_context(properties, user_context)
62
+ return properties unless user_context.is_a?(Hash)
63
+
64
+ # Add user context with proper prefixing for analytics systems
65
+ user_properties = {}
66
+ user_context.each do |key, value|
67
+ # Use $user_ prefix for PostHog compatibility
68
+ user_key = key.to_s.start_with?('$user_') ? key : "$user_#{key}"
69
+ user_properties[user_key] = value
70
+ end
71
+
72
+ properties.merge(user_properties)
73
+ end
74
+
33
75
  # Flushes any pending events if the backend supports it.
34
76
  # This is useful for testing or when you need to ensure events are sent before the process exits.
35
77
  def flush
@@ -52,21 +52,9 @@ module Boxcars
52
52
  # @param properties [Hash] A hash of properties for the event.
53
53
  # It's recommended to include a `:user_id` for user-specific tracking.
54
54
  def track(event:, properties:)
55
- # Ensure properties is a hash, duplicate to avoid mutation by PostHog or other backends
56
- tracking_properties = properties.is_a?(Hash) ? properties.dup : {}
57
-
58
- distinct_id = tracking_properties.delete(:user_id) || tracking_properties.delete('user_id') || "anonymous_user"
59
-
60
- # The PostHog gem's capture method handles distinct_id and properties.
61
- # It's important that distinct_id is a string.
62
- @posthog_client.capture(
63
- distinct_id: distinct_id.to_s, # Ensure distinct_id is a string
64
- event: event.to_s, # Ensure event name is a string
65
- properties: tracking_properties
66
- )
67
- # The posthog-ruby client handles flushing events asynchronously.
68
- # If immediate flushing is needed for testing or specific scenarios:
69
- # @posthog_client.flush
55
+ properties = {} unless properties.is_a?(Hash)
56
+ distinct_id = properties.delete(:user_id) || current_user_id || "anonymous_user"
57
+ @posthog_client.capture(distinct_id:, event:, properties:)
70
58
  end
71
59
 
72
60
  # Flushes any pending events to PostHog immediately.
@@ -74,5 +62,12 @@ module Boxcars
74
62
  def flush
75
63
  @posthog_client.flush if @posthog_client.respond_to?(:flush)
76
64
  end
65
+
66
+ # in Rails, this is a way to find the current user id
67
+ def current_user_id
68
+ return unless defined?(::Current) && ::Current.respond_to?(:user)
69
+
70
+ ::Current.user&.id
71
+ end
77
72
  end
78
73
  end
@@ -52,5 +52,45 @@ module Boxcars
52
52
  def self.err(note, **)
53
53
  new(note:, status: :error, **)
54
54
  end
55
+
56
+ # create a new Observation with user context
57
+ # @param note [String] The text to use for the observation
58
+ # @param user_context [Hash] User information (e.g., { id: 123, email: "user@example.com", role: "admin" })
59
+ # @param status [Symbol] :ok or :error
60
+ # @param added_context [Hash] Any additional context to add to the result
61
+ # @return [Boxcars::Observation] The observation
62
+ def self.with_user(note, user_context:, status: :ok, **)
63
+ new(note:, status:, user_context:, **)
64
+ end
65
+
66
+ # create a new Observation with user context and status :ok
67
+ # @param note [String] The text to use for the observation
68
+ # @param user_context [Hash] User information (e.g., { id: 123, email: "user@example.com", role: "admin" })
69
+ # @param added_context [Hash] Any additional context to add to the result
70
+ # @return [Boxcars::Observation] The observation
71
+ def self.ok_with_user(note, user_context:, **)
72
+ with_user(note, user_context:, status: :ok, **)
73
+ end
74
+
75
+ # create a new Observation with user context and status :error
76
+ # @param note [String] The text to use for the observation
77
+ # @param user_context [Hash] User information (e.g., { id: 123, email: "user@example.com", role: "admin" })
78
+ # @param added_context [Hash] Any additional context to add to the result
79
+ # @return [Boxcars::Observation] The observation
80
+ def self.err_with_user(note, user_context:, **)
81
+ with_user(note, user_context:, status: :error, **)
82
+ end
83
+
84
+ # Extract user context from the observation
85
+ # @return [Hash, nil] The user context if present
86
+ def user_context
87
+ added_context[:user_context]
88
+ end
89
+
90
+ # Check if this observation has user context
91
+ # @return [Boolean] true if user context is present
92
+ def user_context?
93
+ !user_context.nil?
94
+ end
55
95
  end
56
96
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Boxcars
4
4
  # The current version of the gem.
5
- VERSION = "0.8.2"
5
+ VERSION = "0.8.4"
6
6
  end
data/lib/boxcars.rb CHANGED
@@ -218,6 +218,7 @@ module Boxcars
218
218
  end
219
219
 
220
220
  require_relative "boxcars/version"
221
+ require_relative "boxcars/observation"
221
222
  require_relative "boxcars/observability_backend"
222
223
  require_relative "boxcars/observability"
223
224
  # If users want it, they can require 'boxcars/observability_backends/multi_backend'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: boxcars
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ version: 0.8.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Francis Sullivan
@@ -139,9 +139,9 @@ files:
139
139
  - Gemfile
140
140
  - Gemfile.lock
141
141
  - LICENSE.txt
142
- - POSTHOG_TEST_README.md
143
142
  - README.md
144
143
  - Rakefile
144
+ - USER_CONTEXT_GUIDE.md
145
145
  - bin/console
146
146
  - bin/setup
147
147
  - boxcars.gemspec
@@ -1,118 +0,0 @@
1
- # Boxcars Engines PostHog Observability Test
2
-
3
- This test program demonstrates how to use the Boxcars library with PostHog observability backend to track AI engine usage.
4
-
5
- ## Prerequisites
6
-
7
- 1. **PostHog Ruby Gem**: Install the required gem
8
- ```bash
9
- gem install posthog-ruby
10
- ```
11
-
12
- 2. **Environment Variables**: Ensure your `.env` file contains:
13
- ```
14
- POSTHOG_API_KEY=your_posthog_project_api_key
15
- POSTHOG_HOST=https://app.posthog.com # or your self-hosted instance
16
- ```
17
-
18
- 3. **AI Provider API Keys**: For testing different engines, you'll need:
19
- ```
20
- openai_access_token=your_openai_token
21
- GOOGLE_API_KEY=your_google_api_key
22
- ANTHROPIC_API_KEY=your_anthropic_key
23
- GROQ_API_KEY=your_groq_key
24
- ```
25
-
26
- ## Usage
27
-
28
- ### Method 1: IRB Interactive Session (Recommended)
29
-
30
- Start IRB with the required dependencies:
31
-
32
- ```bash
33
- irb -r dotenv/load -r boxcars -r debug -r boxcars/observability_backends/posthog_backend
34
- ```
35
-
36
- Then in the IRB session:
37
-
38
- ```ruby
39
- # Load and run the test
40
- load 'test_engines_with_posthog.rb'
41
-
42
- # Or set up PostHog backend manually:
43
- Boxcars::Observability.backend = Boxcars::PosthogBackend.new(
44
- api_key: ENV['POSTHOG_API_KEY'],
45
- host: ENV['POSTHOG_HOST']
46
- )
47
-
48
- # Run manual tests
49
- manual_test(model: 'gpt-4o', prompt: 'What is machine learning?')
50
- manual_test(model: 'flash', prompt: 'Explain Ruby in one sentence')
51
- manual_test(model: 'sonnet', prompt: 'Write a short poem about coding')
52
- ```
53
-
54
- ### Method 2: Direct Ruby Execution
55
-
56
- ```bash
57
- ruby test_engines_with_posthog.rb
58
- ```
59
-
60
- ## What the Test Does
61
-
62
- 1. **Initializes PostHog Backend**: Sets up the PostHog observability backend with your API credentials
63
- 2. **Tests Multiple Engines**: Runs tests against various AI engines:
64
- - Gemini Flash (Default)
65
- - GPT-4o (OpenAI)
66
- - Claude Sonnet (Anthropic)
67
- - Groq Llama
68
- 3. **Tracks Observability Events**: Each API call generates PostHog events with AI-specific properties
69
- 4. **Provides Manual Testing**: Includes a `manual_test` function for interactive testing
70
-
71
- ## PostHog Events
72
-
73
- The test will generate events in PostHog with properties like:
74
-
75
- - `$ai_model`: The AI model used (e.g., "gpt-4o", "gemini-2.5-flash")
76
- - `$ai_provider`: The provider (e.g., "openai", "google", "anthropic")
77
- - `$ai_input_tokens`: Number of input tokens
78
- - `$ai_output_tokens`: Number of output tokens
79
- - `$ai_latency`: Response time in seconds
80
- - `$ai_http_status`: HTTP status code
81
- - `$ai_trace_id`: Unique trace identifier
82
- - `$ai_is_error`: Boolean indicating if there was an error
83
-
84
- ## Viewing Results
85
-
86
- After running the test:
87
-
88
- 1. Go to your PostHog dashboard
89
- 2. Navigate to Events or Live Events
90
- 3. Look for events with AI-related properties
91
- 4. You can create insights and dashboards to analyze AI usage patterns
92
-
93
- ## Troubleshooting
94
-
95
- - **Missing PostHog gem**: Install with `gem install posthog-ruby`
96
- - **Missing API keys**: Check your `.env` file has the required keys
97
- - **Engine errors**: Some engines may fail if you don't have valid API keys for those providers
98
- - **No events in PostHog**: Check your PostHog API key and host configuration
99
-
100
- ## Available Models
101
-
102
- You can test with these model aliases:
103
-
104
- - `flash` - Gemini 2.5 Flash
105
- - `gpt-4o` - OpenAI GPT-4o
106
- - `sonnet` - Claude Sonnet
107
- - `groq` - Groq Llama
108
- - `online` - Perplexity Sonar
109
- - And many more (see `lib/boxcars/engines.rb`)
110
-
111
- ## Example Manual Tests
112
-
113
- ```ruby
114
- # Test different models
115
- manual_test(model: 'flash', prompt: 'Explain quantum computing')
116
- manual_test(model: 'gpt-4o', prompt: 'Write a Python function to sort a list')
117
- manual_test(model: 'sonnet', prompt: 'What are the benefits of functional programming?')
118
- manual_test(model: 'groq', prompt: 'Describe the difference between AI and ML')