tracebook 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.yardopts +10 -0
- data/CHANGELOG.md +43 -0
- data/MIT-LICENSE +20 -0
- data/README.md +881 -0
- data/Rakefile +21 -0
- data/app/assets/images/tracebook/.keep +0 -0
- data/app/assets/javascripts/tracebook/application.js +88 -0
- data/app/assets/stylesheets/tracebook/application.css +173 -0
- data/app/controllers/concerns/.keep +0 -0
- data/app/controllers/tracebook/application_controller.rb +4 -0
- data/app/controllers/tracebook/exports_controller.rb +25 -0
- data/app/controllers/tracebook/interactions_controller.rb +71 -0
- data/app/helpers/tracebook/application_helper.rb +4 -0
- data/app/helpers/tracebook/interactions_helper.rb +35 -0
- data/app/jobs/tracebook/application_job.rb +5 -0
- data/app/jobs/tracebook/daily_rollups_job.rb +100 -0
- data/app/jobs/tracebook/export_job.rb +162 -0
- data/app/jobs/tracebook/persist_interaction_job.rb +160 -0
- data/app/mailers/tracebook/application_mailer.rb +6 -0
- data/app/models/concerns/.keep +0 -0
- data/app/models/tracebook/application_record.rb +5 -0
- data/app/models/tracebook/interaction.rb +100 -0
- data/app/models/tracebook/pricing_rule.rb +84 -0
- data/app/models/tracebook/redaction_rule.rb +81 -0
- data/app/models/tracebook/rollup_daily.rb +73 -0
- data/app/views/layouts/tracebook/application.html.erb +18 -0
- data/app/views/tracebook/interactions/index.html.erb +105 -0
- data/app/views/tracebook/interactions/show.html.erb +44 -0
- data/config/routes.rb +8 -0
- data/db/migrate/20241112000100_create_tracebook_interactions.rb +55 -0
- data/db/migrate/20241112000200_create_tracebook_rollups_dailies.rb +24 -0
- data/db/migrate/20241112000300_create_tracebook_pricing_rules.rb +21 -0
- data/db/migrate/20241112000400_create_tracebook_redaction_rules.rb +19 -0
- data/lib/tasks/tracebook_tasks.rake +4 -0
- data/lib/tasks/yard.rake +29 -0
- data/lib/tracebook/adapters/active_agent.rb +82 -0
- data/lib/tracebook/adapters/ruby_llm.rb +97 -0
- data/lib/tracebook/adapters.rb +6 -0
- data/lib/tracebook/config.rb +130 -0
- data/lib/tracebook/engine.rb +5 -0
- data/lib/tracebook/errors.rb +9 -0
- data/lib/tracebook/mappers/anthropic.rb +59 -0
- data/lib/tracebook/mappers/base.rb +38 -0
- data/lib/tracebook/mappers/ollama.rb +49 -0
- data/lib/tracebook/mappers/openai.rb +75 -0
- data/lib/tracebook/mappers.rb +283 -0
- data/lib/tracebook/normalized_interaction.rb +86 -0
- data/lib/tracebook/pricing/calculator.rb +39 -0
- data/lib/tracebook/pricing.rb +5 -0
- data/lib/tracebook/redaction_pipeline.rb +88 -0
- data/lib/tracebook/redactors/base.rb +29 -0
- data/lib/tracebook/redactors/card_pan.rb +15 -0
- data/lib/tracebook/redactors/email.rb +15 -0
- data/lib/tracebook/redactors/phone.rb +15 -0
- data/lib/tracebook/redactors.rb +8 -0
- data/lib/tracebook/result.rb +53 -0
- data/lib/tracebook/version.rb +3 -0
- data/lib/tracebook.rb +201 -0
- metadata +164 -0
data/README.md
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
# TraceBook
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/tracebook)
|
|
4
|
+
[](https://github.com/dpaluy/tracebook/actions/workflows/ci.yml)
|
|
5
|
+
|
|
6
|
+
TraceBook is a Rails engine that ingests, redacts, encrypts, and reviews LLM interactions. It ships with a Hotwire UI, cost tracking, rollup analytics, and adapters for popular Ruby LLM libraries.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- **Privacy-first**: Request/response payloads are redacted (PII removal) and encrypted at rest
|
|
11
|
+
- **Cost tracking**: Automatic token usage and cost calculation per provider/model
|
|
12
|
+
- **Review workflow**: Approve, flag, or reject interactions with audit trail
|
|
13
|
+
- **Hierarchical sessions**: Track parent-child relationships for agent chains
|
|
14
|
+
- **Analytics**: Daily rollups for reporting and cost analysis
|
|
15
|
+
- **Flexible adapters**: Built-in support for multiple providers; easy to extend
|
|
16
|
+
- **Production-ready**: Async job processing, export to CSV/NDJSON, filterable dashboards
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Ruby 3.2+
|
|
21
|
+
- Rails 8.1+
|
|
22
|
+
- ActiveJob backend (`:async` for development; Sidekiq/SolidQueue for production)
|
|
23
|
+
- Database with JSONB support (PostgreSQL recommended)
|
|
24
|
+
|
|
25
|
+
## Table of Contents
|
|
26
|
+
|
|
27
|
+
- [Installation](#installation--setup)
|
|
28
|
+
- [Configuration](#configuration)
|
|
29
|
+
- [Capturing Interactions](#capturing-interactions)
|
|
30
|
+
- [Manual API](#manual-api)
|
|
31
|
+
- [Built-in Adapters](#built-in-adapters)
|
|
32
|
+
- [Creating Custom Adapters](#creating-custom-adapters)
|
|
33
|
+
- [Creating Custom Mappers](#creating-custom-mappers)
|
|
34
|
+
- [Cost Tracking](#cost-tracking)
|
|
35
|
+
- [Reviewing Data](#reviewing-data)
|
|
36
|
+
- [Production Setup](#production-setup)
|
|
37
|
+
- [Development & Testing](#development--testing)
|
|
38
|
+
|
|
39
|
+
## Installation & Setup
|
|
40
|
+
|
|
41
|
+
### 1. Add the gem
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
# Gemfile
|
|
45
|
+
gem "tracebook" # or path: "../gems/tracebook" for local development
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bundle install
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 2. Install migrations
|
|
53
|
+
|
|
54
|
+
Rails engines keep migrations inside the gem. Copy them into the host app and migrate:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bin/rails railties:install:migrations FROM=tracebook
|
|
58
|
+
bin/rails db:migrate
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
This creates the following tables:
|
|
62
|
+
|
|
63
|
+
- **`tracebook_interactions`** — Main table for LLM calls
|
|
64
|
+
- Encrypted columns: `request`, `response`, `review_comment`
|
|
65
|
+
- Indexes: `provider`, `model`, `review_state`, `session_id`, `parent_id`, `occurred_at`
|
|
66
|
+
- Fields: provider, model, request (JSONB), response (JSONB), usage stats, duration, review metadata, tags, etc.
|
|
67
|
+
|
|
68
|
+
- **`tracebook_pricing_rules`** — Cost per 1k tokens for provider/model patterns
|
|
69
|
+
- Fields: `provider`, `model_pattern` (glob), `input_per_1k`, `output_per_1k`, `currency`, `effective_from`
|
|
70
|
+
- Example: `provider: "openai", model_pattern: "gpt-4o*", input_per_1k: 2.50, output_per_1k: 10.00`
|
|
71
|
+
|
|
72
|
+
- **`tracebook_redaction_rules`** — PII detection patterns
|
|
73
|
+
- Fields: `name`, `pattern` (regex), `detector` (class name), `replacement`, `enabled`
|
|
74
|
+
- Built-in rules: email addresses, SSNs, credit cards, phone numbers
|
|
75
|
+
|
|
76
|
+
- **`tracebook_rollup_daily`** — Aggregated metrics by date/provider/model/project
|
|
77
|
+
- Composite PK: `(date, provider, model, project)`
|
|
78
|
+
- Fields: call counts, token sums, cost totals, error rates, avg duration
|
|
79
|
+
|
|
80
|
+
Re-run `railties:install:migrations` whenever the gem adds new migrations.
|
|
81
|
+
|
|
82
|
+
### 3. Mount the engine
|
|
83
|
+
|
|
84
|
+
Update `config/routes.rb` to expose the UI:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
# config/routes.rb
|
|
88
|
+
Rails.application.routes.draw do
|
|
89
|
+
# Wrap with authentication/authorization
|
|
90
|
+
authenticate :user, ->(u) { u.admin? } do
|
|
91
|
+
mount TraceBook::Engine => "/tracebook"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Or use a constraint
|
|
95
|
+
constraints -> (req) { req.session[:user_id] && User.find(req.session[:user_id])&.admin? } do
|
|
96
|
+
mount TraceBook::Engine => "/tracebook"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Only trusted reviewers should access the dashboard.
|
|
102
|
+
|
|
103
|
+
### 4. Configure encryption
|
|
104
|
+
|
|
105
|
+
TraceBook uses ActiveRecord::Encryption to protect sensitive data. Generate keys and configure in your credentials:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# Generate encryption keys
|
|
109
|
+
bin/rails db:encryption:init
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
This outputs:
|
|
113
|
+
|
|
114
|
+
```yaml
|
|
115
|
+
active_record_encryption:
|
|
116
|
+
primary_key: [generated_key]
|
|
117
|
+
deterministic_key: [generated_key]
|
|
118
|
+
key_derivation_salt: [generated_salt]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Add these to `config/credentials.yml.enc`:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
EDITOR=vim bin/rails credentials:edit
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```yaml
|
|
128
|
+
# config/credentials.yml.enc
|
|
129
|
+
active_record_encryption:
|
|
130
|
+
primary_key: <generated_key>
|
|
131
|
+
deterministic_key: <generated_key>
|
|
132
|
+
key_derivation_salt: <generated_salt>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Without these keys, TraceBook will raise an error when persisting interactions.
|
|
136
|
+
|
|
137
|
+
## Configuration
|
|
138
|
+
|
|
139
|
+
Create `config/initializers/tracebook.rb`:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
TraceBook.configure do |config|
|
|
143
|
+
# ========================================
|
|
144
|
+
# REQUIRED: Authorization
|
|
145
|
+
# ========================================
|
|
146
|
+
# This proc receives (user, action, resource) and must return true/false
|
|
147
|
+
# Actions: :read, :review, :export
|
|
148
|
+
config.authorize = ->(user, action, resource) do
|
|
149
|
+
case action
|
|
150
|
+
when :read
|
|
151
|
+
user&.admin? || user&.reviewer?
|
|
152
|
+
when :review
|
|
153
|
+
user&.admin?
|
|
154
|
+
when :export
|
|
155
|
+
user&.admin?
|
|
156
|
+
else
|
|
157
|
+
false
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# ========================================
|
|
162
|
+
# OPTIONAL: Project & Metadata
|
|
163
|
+
# ========================================
|
|
164
|
+
# Project identifier for this application
|
|
165
|
+
config.project_name = "TraceBook Dashboard"
|
|
166
|
+
|
|
167
|
+
# ========================================
|
|
168
|
+
# OPTIONAL: Persistence
|
|
169
|
+
# ========================================
|
|
170
|
+
# Use async jobs for persistence (recommended for production)
|
|
171
|
+
# When true, TraceBook.record! enqueues PersistInteractionJob
|
|
172
|
+
# When false, writes happen inline (useful for tests)
|
|
173
|
+
config.persist_async = Rails.env.production?
|
|
174
|
+
|
|
175
|
+
# Payload size threshold for ActiveStorage spillover (bytes)
|
|
176
|
+
# Interactions larger than this are stored in ActiveStorage
|
|
177
|
+
config.inline_payload_bytes = 64 * 1024 # 64KB
|
|
178
|
+
|
|
179
|
+
# ========================================
|
|
180
|
+
# OPTIONAL: Cost Tracking
|
|
181
|
+
# ========================================
|
|
182
|
+
# Default currency for cost calculations
|
|
183
|
+
config.default_currency = "USD"
|
|
184
|
+
|
|
185
|
+
# ========================================
|
|
186
|
+
# OPTIONAL: Export
|
|
187
|
+
# ========================================
|
|
188
|
+
# Available export formats
|
|
189
|
+
config.export_formats = %i[csv ndjson]
|
|
190
|
+
|
|
191
|
+
# ========================================
|
|
192
|
+
# OPTIONAL: Redaction
|
|
193
|
+
# ========================================
|
|
194
|
+
# Custom PII redactors (in addition to built-in email/SSN/etc)
|
|
195
|
+
config.redactors += [
|
|
196
|
+
->(payload) {
|
|
197
|
+
# Example: Redact API keys
|
|
198
|
+
payload.gsub(/api_key["\s]*[:=]["\s]*\K[\w-]+/, "[REDACTED]")
|
|
199
|
+
},
|
|
200
|
+
->(payload) {
|
|
201
|
+
# Example: Redact authorization headers
|
|
202
|
+
payload.gsub(/authorization["\s]*:["\s]*\K[^"]+/, "[REDACTED]")
|
|
203
|
+
}
|
|
204
|
+
]
|
|
205
|
+
end
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Important**: The `authorize` proc is mandatory. TraceBook will raise an error on boot if it's missing.
|
|
209
|
+
|
|
210
|
+
Configuration is frozen after the block runs. Call `TraceBook.reset_configuration!` in tests when you need a clean slate.
|
|
211
|
+
|
|
212
|
+
## Capturing Interactions
|
|
213
|
+
|
|
214
|
+
### Manual API
|
|
215
|
+
|
|
216
|
+
Call `TraceBook.record!` anywhere you have access to an LLM request/response:
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
TraceBook.record!(
|
|
220
|
+
provider: "openai",
|
|
221
|
+
model: "gpt-4o-mini",
|
|
222
|
+
project: "support",
|
|
223
|
+
request_payload: { messages: messages, temperature: 0.2 },
|
|
224
|
+
response_payload: response_body,
|
|
225
|
+
input_tokens: usage[:prompt_tokens],
|
|
226
|
+
output_tokens: usage[:completion_tokens],
|
|
227
|
+
latency_ms: 187,
|
|
228
|
+
status: :success,
|
|
229
|
+
tags: %w[triage priority],
|
|
230
|
+
metadata: { ticket_id: ticket.id },
|
|
231
|
+
user: current_user,
|
|
232
|
+
session_id: session_id,
|
|
233
|
+
parent_id: parent_interaction_id
|
|
234
|
+
)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Parameters:**
|
|
238
|
+
|
|
239
|
+
- **Required:**
|
|
240
|
+
- `provider` (String) — LLM provider name (e.g., "openai", "anthropic", "ollama")
|
|
241
|
+
- `model` (String) — Model identifier (e.g., "gpt-4o", "claude-3-5-sonnet-20241022")
|
|
242
|
+
|
|
243
|
+
- **Optional:**
|
|
244
|
+
- `project` (String) — Project/app name for filtering
|
|
245
|
+
- `request_payload` (Hash) — Full request sent to provider
|
|
246
|
+
- `response_payload` (Hash) — Full response from provider
|
|
247
|
+
- `request_text` (String) — Human-readable request summary
|
|
248
|
+
- `response_text` (String) — Human-readable response summary
|
|
249
|
+
- `input_tokens` (Integer) — Prompt token count
|
|
250
|
+
- `output_tokens` (Integer) — Completion token count
|
|
251
|
+
- `latency_ms` (Integer) — Request duration in milliseconds
|
|
252
|
+
- `status` (Symbol) — `:success`, `:error`, `:canceled`
|
|
253
|
+
- `error_class` (String) — Exception class name on failure
|
|
254
|
+
- `error_message` (String) — Exception message on failure
|
|
255
|
+
- `tags` (Array<String>) — Labels for filtering (e.g., ["prod", "high-priority"])
|
|
256
|
+
- `metadata` (Hash) — Custom metadata (e.g., `{ ticket_id: 123 }`)
|
|
257
|
+
- `user` (ActiveRecord object) — Associated user (polymorphic)
|
|
258
|
+
- `session_id` (String) — Session identifier for grouping related calls
|
|
259
|
+
- `parent_id` (Integer) — Parent `Interaction` ID for hierarchical chains
|
|
260
|
+
|
|
261
|
+
**Return value:**
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
result = TraceBook.record!(...)
|
|
265
|
+
result.success? # => true/false
|
|
266
|
+
result.error # => exception when persistence failed
|
|
267
|
+
result.interaction # => AR record when persisted inline (persist_async = false)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
When `config.persist_async = true`, the interaction is enqueued via `Tracebook::PersistInteractionJob`.
|
|
271
|
+
|
|
272
|
+
### Background Jobs & Rollups
|
|
273
|
+
|
|
274
|
+
**PersistInteractionJob** handles redaction, encryption, cost calculation, and writes the `Interaction` record.
|
|
275
|
+
|
|
276
|
+
**DailyRollupsJob** summarizes counts, token totals, and cost into `RollupDaily` rows. Schedule it nightly per provider/model/project:
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
# Example: Schedule with Sidekiq Cron or whenever
|
|
280
|
+
Tracebook::DailyRollupsJob.perform_later(
|
|
281
|
+
date: Date.yesterday,
|
|
282
|
+
provider: "openai",
|
|
283
|
+
model: "gpt-4o",
|
|
284
|
+
project: nil
|
|
285
|
+
)
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Wrap this in your scheduler to cover all active provider/model/project combinations.
|
|
289
|
+
|
|
290
|
+
**ExportJob** streams large CSV/NDJSON exports respecting your filters.
|
|
291
|
+
|
|
292
|
+
## Built-in Adapters
|
|
293
|
+
|
|
294
|
+
TraceBook ships with adapters that automatically capture LLM interactions from popular libraries. Adapters normalize provider-specific responses and call `TraceBook.record!`, so you get instrumentation without modifying application code.
|
|
295
|
+
|
|
296
|
+
### RubyLLM Adapter
|
|
297
|
+
|
|
298
|
+
The RubyLLM adapter subscribes to `ActiveSupport::Notifications` events (default: `ruby_llm.request`).
|
|
299
|
+
|
|
300
|
+
**Setup:**
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
# config/initializers/tracebook_adapters.rb
|
|
304
|
+
TraceBook::Adapters::RubyLLM.enable!
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**Emit events from your LLM client:**
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
# Example: Wrapping an OpenAI client call
|
|
311
|
+
class OpenAIService
|
|
312
|
+
def chat_completion(messages:, model: "gpt-4o", **options)
|
|
313
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
314
|
+
|
|
315
|
+
request = {
|
|
316
|
+
model: model,
|
|
317
|
+
messages: messages,
|
|
318
|
+
**options
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
begin
|
|
322
|
+
response = openai_client.chat(parameters: request)
|
|
323
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).to_i
|
|
324
|
+
|
|
325
|
+
ActiveSupport::Notifications.instrument("ruby_llm.request", {
|
|
326
|
+
provider: "openai",
|
|
327
|
+
request: request,
|
|
328
|
+
response: response,
|
|
329
|
+
meta: {
|
|
330
|
+
project: "support-chatbot",
|
|
331
|
+
tags: ["customer-support", "triage"],
|
|
332
|
+
user: current_user,
|
|
333
|
+
session_id: session.id,
|
|
334
|
+
latency_ms: elapsed_ms,
|
|
335
|
+
status: :success
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
response
|
|
340
|
+
rescue => e
|
|
341
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).to_i
|
|
342
|
+
|
|
343
|
+
ActiveSupport::Notifications.instrument("ruby_llm.request", {
|
|
344
|
+
provider: "openai",
|
|
345
|
+
request: request,
|
|
346
|
+
response: nil,
|
|
347
|
+
meta: {
|
|
348
|
+
project: "support-chatbot",
|
|
349
|
+
user: current_user,
|
|
350
|
+
session_id: session.id,
|
|
351
|
+
latency_ms: elapsed_ms,
|
|
352
|
+
status: :error,
|
|
353
|
+
error_class: e.class.name,
|
|
354
|
+
error_message: e.message
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
raise
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
private
|
|
363
|
+
|
|
364
|
+
def openai_client
|
|
365
|
+
@openai_client ||= OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
**Supported providers:** OpenAI, Anthropic, Ollama (built-in mappers). Other providers use the fallback mapper.
|
|
371
|
+
|
|
372
|
+
**Custom event name:**
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
# If your library uses a different event name
|
|
376
|
+
TraceBook::Adapters::RubyLLM.enable!(instrumentation: "my_llm.complete")
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
**Disabling:**
|
|
380
|
+
|
|
381
|
+
```ruby
|
|
382
|
+
# In test environment or when switching instrumentation
|
|
383
|
+
TraceBook::Adapters::RubyLLM.disable!
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### ActiveAgent Adapter
|
|
387
|
+
|
|
388
|
+
For applications using ActiveAgent (agentic frameworks), enable the bus adapter:
|
|
389
|
+
|
|
390
|
+
```ruby
|
|
391
|
+
# config/initializers/tracebook_adapters.rb
|
|
392
|
+
TraceBook::Adapters::ActiveAgent.enable!(bus: ActiveAgent::Bus)
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
The adapter automatically captures agent interactions including parent-child relationships for hierarchical agent chains.
|
|
396
|
+
|
|
397
|
+
**Note:** If you omit `bus:`, the adapter attempts to locate `ActiveAgent::Bus` automatically when loaded.
|
|
398
|
+
|
|
399
|
+
## Creating Custom Adapters
|
|
400
|
+
|
|
401
|
+
Adapters follow a simple pattern:
|
|
402
|
+
|
|
403
|
+
1. Listen to whatever instrumentation your LLM client exposes (Notifications, middleware, observers, etc.)
|
|
404
|
+
2. Normalize the payload using `Tracebook::Mappers.normalize` or build a `NormalizedInteraction` manually
|
|
405
|
+
3. Call `TraceBook.record!(**normalized.to_h)`
|
|
406
|
+
|
|
407
|
+
**Example: Custom adapter for Langchain.rb**
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
# lib/tracebook/adapters/langchain_rb.rb
|
|
411
|
+
module Tracebook
|
|
412
|
+
module Adapters
|
|
413
|
+
module LangchainRb
|
|
414
|
+
extend self
|
|
415
|
+
|
|
416
|
+
def enable!
|
|
417
|
+
return if @enabled
|
|
418
|
+
|
|
419
|
+
# Hook into Langchain's middleware or callback system
|
|
420
|
+
::Langchain::LLM::Base.after_completion do |llm, request, response, duration|
|
|
421
|
+
handle_completion(
|
|
422
|
+
provider: llm.class.provider_name,
|
|
423
|
+
request: request,
|
|
424
|
+
response: response,
|
|
425
|
+
duration_ms: (duration * 1000).to_i,
|
|
426
|
+
meta: {
|
|
427
|
+
project: "langchain-app",
|
|
428
|
+
user: Current.user,
|
|
429
|
+
session_id: Current.session_id
|
|
430
|
+
}
|
|
431
|
+
)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
@enabled = true
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def disable!
|
|
438
|
+
# Unhook callback
|
|
439
|
+
@enabled = false
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
private
|
|
443
|
+
|
|
444
|
+
def handle_completion(provider:, request:, response:, duration_ms:, meta:)
|
|
445
|
+
normalized = Tracebook::Mappers.normalize(
|
|
446
|
+
provider,
|
|
447
|
+
raw_request: request,
|
|
448
|
+
raw_response: response,
|
|
449
|
+
meta: meta.merge(latency_ms: duration_ms)
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
TraceBook.record!(**normalized.to_h)
|
|
453
|
+
rescue => error
|
|
454
|
+
Rails.logger.error("TraceBook LangchainRb adapter error: #{error.message}")
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
TraceBook = Tracebook unless defined?(TraceBook)
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
**Enable your adapter:**
|
|
464
|
+
|
|
465
|
+
```ruby
|
|
466
|
+
# config/initializers/tracebook_adapters.rb
|
|
467
|
+
require "tracebook/adapters/langchain_rb"
|
|
468
|
+
Tracebook::Adapters::LangchainRb.enable!
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
## Creating Custom Mappers
|
|
472
|
+
|
|
473
|
+
Mappers normalize provider-specific request/response formats into TraceBook's standard schema. Create a custom mapper when the built-in ones (OpenAI, Anthropic, Ollama) don't match your provider's format.
|
|
474
|
+
|
|
475
|
+
**Example: Custom mapper for Cohere**
|
|
476
|
+
|
|
477
|
+
```ruby
|
|
478
|
+
# lib/tracebook/mappers/cohere.rb
|
|
479
|
+
module Tracebook
|
|
480
|
+
module Mappers
|
|
481
|
+
class Cohere < Base
|
|
482
|
+
def self.normalize(raw_request:, raw_response:, meta: {})
|
|
483
|
+
new.normalize(
|
|
484
|
+
raw_request: raw_request,
|
|
485
|
+
raw_response: raw_response,
|
|
486
|
+
meta: meta
|
|
487
|
+
)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def normalize(raw_request:, raw_response:, meta: {})
|
|
491
|
+
request = symbolize(raw_request || {})
|
|
492
|
+
response = symbolize(raw_response || {})
|
|
493
|
+
meta_info = indifferent_meta(meta)
|
|
494
|
+
|
|
495
|
+
build_interaction(
|
|
496
|
+
provider: "cohere",
|
|
497
|
+
model: request[:model] || response[:model],
|
|
498
|
+
project: meta_info[:project],
|
|
499
|
+
request_payload: raw_request,
|
|
500
|
+
response_payload: raw_response,
|
|
501
|
+
request_text: request[:message] || request[:prompt],
|
|
502
|
+
response_text: extract_response_text(response),
|
|
503
|
+
input_tokens: extract_token_count(response, :prompt_tokens),
|
|
504
|
+
output_tokens: extract_token_count(response, :completion_tokens),
|
|
505
|
+
latency_ms: meta_info[:latency_ms],
|
|
506
|
+
status: meta_info[:status]&.to_sym || :success,
|
|
507
|
+
error_class: nil,
|
|
508
|
+
error_message: nil,
|
|
509
|
+
tags: Array(meta_info[:tags]).compact,
|
|
510
|
+
metadata: extract_metadata(response),
|
|
511
|
+
user: meta_info[:user],
|
|
512
|
+
parent_id: meta_info[:parent_id],
|
|
513
|
+
session_id: meta_info[:session_id]
|
|
514
|
+
)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
private
|
|
518
|
+
|
|
519
|
+
def extract_response_text(response)
|
|
520
|
+
response[:text] || response.dig(:generations, 0, :text)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def extract_token_count(response, key)
|
|
524
|
+
response.dig(:meta, :billed_units, key)&.to_i
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def extract_metadata(response)
|
|
528
|
+
metadata = {}
|
|
529
|
+
metadata["generation_id"] = response[:generation_id] if response[:generation_id]
|
|
530
|
+
metadata["finish_reason"] = response[:finish_reason] if response[:finish_reason]
|
|
531
|
+
compact_hash(metadata)
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
TraceBook = Tracebook unless defined?(TraceBook)
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
**Register your mapper:**
|
|
541
|
+
|
|
542
|
+
```ruby
|
|
543
|
+
# lib/tracebook/mappers.rb
|
|
544
|
+
require_relative "mappers/cohere"
|
|
545
|
+
|
|
546
|
+
module Tracebook
|
|
547
|
+
module Mappers
|
|
548
|
+
def normalize(provider, raw_request:, raw_response:, meta: {})
|
|
549
|
+
case provider.to_s
|
|
550
|
+
when "openai"
|
|
551
|
+
normalize_openai(raw_request, raw_response, meta)
|
|
552
|
+
when "anthropic"
|
|
553
|
+
normalize_anthropic(raw_request, raw_response, meta)
|
|
554
|
+
when "ollama"
|
|
555
|
+
normalize_ollama(raw_request, raw_response, meta)
|
|
556
|
+
when "cohere"
|
|
557
|
+
Mappers::Cohere.normalize(
|
|
558
|
+
raw_request: raw_request,
|
|
559
|
+
raw_response: raw_response,
|
|
560
|
+
meta: meta
|
|
561
|
+
)
|
|
562
|
+
else
|
|
563
|
+
fallback_normalized(provider, raw_request, raw_response, meta)
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
**Mapper requirements:**
|
|
571
|
+
|
|
572
|
+
- Inherit from `Tracebook::Mappers::Base`
|
|
573
|
+
- Implement `.normalize(raw_request:, raw_response:, meta:)`
|
|
574
|
+
- Return a `Tracebook::NormalizedInteraction` instance
|
|
575
|
+
- Handle missing fields gracefully (return `nil` for unavailable data)
|
|
576
|
+
- Extract token counts if available, otherwise leave as `nil`
|
|
577
|
+
|
|
578
|
+
## Cost Tracking
|
|
579
|
+
|
|
580
|
+
TraceBook automatically calculates costs based on `PricingRule` records. Create pricing rules for your providers/models:
|
|
581
|
+
|
|
582
|
+
```ruby
|
|
583
|
+
# db/seeds.rb or a migration
|
|
584
|
+
|
|
585
|
+
# OpenAI pricing (as of 2024)
|
|
586
|
+
TraceBook::PricingRule.create!(
|
|
587
|
+
provider: "openai",
|
|
588
|
+
model_pattern: "gpt-4o",
|
|
589
|
+
input_per_1k: 2.50,
|
|
590
|
+
output_per_1k: 10.00,
|
|
591
|
+
currency: "USD",
|
|
592
|
+
effective_from: Date.new(2024, 8, 6)
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
TraceBook::PricingRule.create!(
|
|
596
|
+
provider: "openai",
|
|
597
|
+
model_pattern: "gpt-4o-mini",
|
|
598
|
+
input_per_1k: 0.150,
|
|
599
|
+
output_per_1k: 0.600,
|
|
600
|
+
currency: "USD",
|
|
601
|
+
effective_from: Date.new(2024, 7, 18)
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
TraceBook::PricingRule.create!(
|
|
605
|
+
provider: "openai",
|
|
606
|
+
model_pattern: "o1",
|
|
607
|
+
input_per_1k: 15.00,
|
|
608
|
+
output_per_1k: 60.00,
|
|
609
|
+
currency: "USD",
|
|
610
|
+
effective_from: Date.new(2024, 12, 17)
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
# Anthropic pricing
|
|
614
|
+
TraceBook::PricingRule.create!(
|
|
615
|
+
provider: "anthropic",
|
|
616
|
+
model_pattern: "claude-3-5-sonnet-*",
|
|
617
|
+
input_per_1k: 3.00,
|
|
618
|
+
output_per_1k: 15.00,
|
|
619
|
+
currency: "USD",
|
|
620
|
+
effective_from: Date.new(2024, 10, 22)
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
TraceBook::PricingRule.create!(
|
|
624
|
+
provider: "anthropic",
|
|
625
|
+
model_pattern: "claude-3-5-haiku-*",
|
|
626
|
+
input_per_1k: 1.00,
|
|
627
|
+
output_per_1k: 5.00,
|
|
628
|
+
currency: "USD",
|
|
629
|
+
effective_from: Date.new(2024, 11, 1)
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
# Ollama (free/local)
|
|
633
|
+
TraceBook::PricingRule.create!(
|
|
634
|
+
provider: "ollama",
|
|
635
|
+
model_pattern: "*",
|
|
636
|
+
input_per_1k: 0.0,
|
|
637
|
+
output_per_1k: 0.0,
|
|
638
|
+
currency: "USD",
|
|
639
|
+
effective_from: Date.new(2024, 1, 1)
|
|
640
|
+
)
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
**Glob patterns:**
|
|
644
|
+
|
|
645
|
+
- `gpt-4o` — Exact match
|
|
646
|
+
- `gpt-4o*` — Matches `gpt-4o`, `gpt-4o-mini`, `gpt-4o-2024-08-06`
|
|
647
|
+
- `claude-3-5-*` — Matches all Claude 3.5 models
|
|
648
|
+
- `*` — Matches everything (fallback rule)
|
|
649
|
+
|
|
650
|
+
TraceBook uses the most specific matching rule. If multiple rules match, it prefers the most recently effective one.
|
|
651
|
+
|
|
652
|
+
## Reviewing Data
|
|
653
|
+
|
|
654
|
+
### Dashboard UI
|
|
655
|
+
|
|
656
|
+
Visit the mount path (`/tracebook` by default) to access the dashboard.
|
|
657
|
+
|
|
658
|
+
**Index screen:**
|
|
659
|
+
|
|
660
|
+
- **Filters**: Provider, model, project, status, review state, tags, date range
|
|
661
|
+
- **KPI tiles**: Total calls, tokens used, total cost, error rate, avg latency
|
|
662
|
+
- **Interaction table**: Columns include:
|
|
663
|
+
- Timestamp
|
|
664
|
+
- Label (first 100 chars of request)
|
|
665
|
+
- User
|
|
666
|
+
- Provider/Model
|
|
667
|
+
- Tokens (input/output)
|
|
668
|
+
- Cost
|
|
669
|
+
- Duration (ms)
|
|
670
|
+
- Review state
|
|
671
|
+
- Actions (Approve/Flag/Reject, detail link)
|
|
672
|
+
|
|
673
|
+
**Detail screen:**
|
|
674
|
+
|
|
675
|
+
- **Header**: ID, label, user, timestamp, review state dropdown + comment form
|
|
676
|
+
- **Metrics panel**: Model, duration, token breakdown, cost, HTTP status
|
|
677
|
+
- **Collapsible sections**:
|
|
678
|
+
- Input (messages)
|
|
679
|
+
- Output (text + tool calls)
|
|
680
|
+
- Full JSON (request/response payloads)
|
|
681
|
+
- Error (if failed)
|
|
682
|
+
- **Sidebar**: Parent/child links, tags, session breadcrumb
|
|
683
|
+
|
|
684
|
+
**Keyboard shortcuts:**
|
|
685
|
+
|
|
686
|
+
- `j/k` — Navigate rows
|
|
687
|
+
- `a` — Approve selected
|
|
688
|
+
- `f` — Flag selected
|
|
689
|
+
- `r` — Reject selected
|
|
690
|
+
- `?` — Show help
|
|
691
|
+
|
|
692
|
+
**Bulk review:**
|
|
693
|
+
|
|
694
|
+
Select multiple interactions using checkboxes, then apply a review state to all at once.
|
|
695
|
+
|
|
696
|
+
### Review Workflow
|
|
697
|
+
|
|
698
|
+
Interactions start in `unreviewed` state. Reviewers can transition to:
|
|
699
|
+
|
|
700
|
+
- **`approved`** — Interaction is acceptable; no issues found
|
|
701
|
+
- **`flagged`** — Interaction requires attention (e.g., sensitive data, unexpected behavior)
|
|
702
|
+
- **`rejected`** — Interaction is problematic and should not have occurred
|
|
703
|
+
|
|
704
|
+
Only `admin` users (as defined in your `authorize` proc) can change review states.
|
|
705
|
+
|
|
706
|
+
## Production Setup
|
|
707
|
+
|
|
708
|
+
### Queue Adapter
|
|
709
|
+
|
|
710
|
+
Configure ActiveJob to use a production queue backend:
|
|
711
|
+
|
|
712
|
+
```ruby
|
|
713
|
+
# config/environments/production.rb
|
|
714
|
+
config.active_job.queue_adapter = :sidekiq # or :solid_queue, etc.
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
### Encryption Keys
|
|
718
|
+
|
|
719
|
+
**Important:** Store encryption keys securely. Never commit them to version control.
|
|
720
|
+
|
|
721
|
+
- Use Rails encrypted credentials (`bin/rails credentials:edit`)
|
|
722
|
+
- Or environment variables with a secrets manager (AWS Secrets Manager, HashiCorp Vault)
|
|
723
|
+
|
|
724
|
+
### Scheduling Rollup Jobs
|
|
725
|
+
|
|
726
|
+
Use a scheduler to run `DailyRollupsJob` nightly:
|
|
727
|
+
|
|
728
|
+
**Sidekiq Cron:**
|
|
729
|
+
|
|
730
|
+
```ruby
|
|
731
|
+
# config/initializers/sidekiq_cron.rb
|
|
732
|
+
Sidekiq::Cron::Job.create(
|
|
733
|
+
name: "TraceBook daily rollups - OpenAI",
|
|
734
|
+
cron: "0 2 * * *", # 2am daily
|
|
735
|
+
class: "Tracebook::DailyRollupsJob",
|
|
736
|
+
kwargs: { date: Date.yesterday, provider: "openai", model: nil, project: nil }
|
|
737
|
+
)
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
**Whenever:**
|
|
741
|
+
|
|
742
|
+
```ruby
|
|
743
|
+
# config/schedule.rb
|
|
744
|
+
every 1.day, at: "2:00 am" do
|
|
745
|
+
runner "Tracebook::DailyRollupsJob.perform_later(date: Date.yesterday, provider: 'openai', model: nil, project: nil)"
|
|
746
|
+
end
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
### Monitoring
|
|
750
|
+
|
|
751
|
+
Add error tracking to catch adapter failures:
|
|
752
|
+
|
|
753
|
+
```ruby
|
|
754
|
+
# config/initializers/tracebook.rb
|
|
755
|
+
TraceBook.configure do |config|
|
|
756
|
+
# Existing config...
|
|
757
|
+
|
|
758
|
+
# Hook into error logging
|
|
759
|
+
config.on_error = ->(error, context) do
|
|
760
|
+
Sentry.capture_exception(error, extra: context) if defined?(Sentry)
|
|
761
|
+
Rails.logger.error("TraceBook error: #{error.message} - #{context.inspect}")
|
|
762
|
+
end
|
|
763
|
+
end
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
### Database Indexes
|
|
767
|
+
|
|
768
|
+
TraceBook migrations include indexes for common queries. If you add custom filters, consider additional indexes:
|
|
769
|
+
|
|
770
|
+
```ruby
|
|
771
|
+
# db/migrate/xxx_add_custom_tracebook_indexes.rb
|
|
772
|
+
class AddCustomTracebookIndexes < ActiveRecord::Migration[7.1]
|
|
773
|
+
def change
|
|
774
|
+
add_index :tracebook_interactions, [:project, :occurred_at], name: "idx_tracebook_project_time"
|
|
775
|
+
add_index :tracebook_interactions, :tags, using: :gin, name: "idx_tracebook_tags"
|
|
776
|
+
end
|
|
777
|
+
end
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
### Data Retention
|
|
781
|
+
|
|
782
|
+
Consider archiving or deleting old interactions to manage database size:
|
|
783
|
+
|
|
784
|
+
```ruby
|
|
785
|
+
# app/jobs/archive_old_interactions_job.rb
|
|
786
|
+
class ArchiveOldInteractionsJob < ApplicationJob
|
|
787
|
+
def perform
|
|
788
|
+
cutoff = 90.days.ago
|
|
789
|
+
|
|
790
|
+
# Option 1: Delete
|
|
791
|
+
TraceBook::Interaction.where("occurred_at < ?", cutoff).delete_all
|
|
792
|
+
|
|
793
|
+
# Option 2: Export to S3 before deleting
|
|
794
|
+
interactions = TraceBook::Interaction.where("occurred_at < ?", cutoff)
|
|
795
|
+
S3Archiver.archive(interactions)
|
|
796
|
+
interactions.delete_all
|
|
797
|
+
end
|
|
798
|
+
end
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
## Development & Testing
|
|
802
|
+
|
|
803
|
+
### Inside the engine repository
|
|
804
|
+
|
|
805
|
+
```bash
|
|
806
|
+
cd tracebook/
|
|
807
|
+
bundle install
|
|
808
|
+
bundle exec rails db:migrate # Run dummy app migrations
|
|
809
|
+
bundle exec rake test # Run full test suite
|
|
810
|
+
bundle exec rubocop --fix-unsafe # Fix style issues
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
### Inside a host application
|
|
814
|
+
|
|
815
|
+
After pulling new migrations from the gem:
|
|
816
|
+
|
|
817
|
+
```bash
|
|
818
|
+
bin/rails railties:install:migrations FROM=tracebook
|
|
819
|
+
bin/rails db:migrate
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
### Testing with adapters disabled
|
|
823
|
+
|
|
824
|
+
```ruby
|
|
825
|
+
# test/test_helper.rb
|
|
826
|
+
class ActiveSupport::TestCase
|
|
827
|
+
setup do
|
|
828
|
+
TraceBook::Adapters::RubyLLM.disable!
|
|
829
|
+
TraceBook.reset_configuration!
|
|
830
|
+
|
|
831
|
+
TraceBook.configure do |config|
|
|
832
|
+
config.authorize = ->(*) { true }
|
|
833
|
+
config.persist_async = false # Inline for tests
|
|
834
|
+
end
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
## API Documentation
|
|
840
|
+
|
|
841
|
+
TraceBook uses [YARD](https://yardoc.org/) for API documentation. The full API docs are available at [rubydoc.info/gems/tracebook](https://rubydoc.info/gems/tracebook).
|
|
842
|
+
|
|
843
|
+
### Generating Documentation Locally
|
|
844
|
+
|
|
845
|
+
```bash
|
|
846
|
+
# Install YARD
|
|
847
|
+
bundle install
|
|
848
|
+
|
|
849
|
+
# Generate documentation
|
|
850
|
+
bundle exec rake yard
|
|
851
|
+
|
|
852
|
+
# Generate and open in browser
|
|
853
|
+
bundle exec rake yard:open
|
|
854
|
+
|
|
855
|
+
# View documentation coverage stats
|
|
856
|
+
bundle exec rake yard:stats
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
Documentation is generated in the `doc/` directory. Open `doc/index.html` in your browser to view.
|
|
860
|
+
|
|
861
|
+
### Key Documentation Areas
|
|
862
|
+
|
|
863
|
+
- **{Tracebook}** - Main module and `record!` method
|
|
864
|
+
- **{Tracebook::Mappers}** - Provider normalization
|
|
865
|
+
- **{Tracebook::Adapters::RubyLLM}** - ActiveSupport::Notifications adapter
|
|
866
|
+
- **{Tracebook::Interaction}** - ActiveRecord model
|
|
867
|
+
- **{Tracebook::NormalizedInteraction}** - Standard data structure
|
|
868
|
+
- **{Tracebook::Result}** - Return value from `record!`
|
|
869
|
+
|
|
870
|
+
## Contributing
|
|
871
|
+
|
|
872
|
+
1. Fork the repo and create a topic branch
|
|
873
|
+
2. Ensure `bundle exec rake test` passes
|
|
874
|
+
3. Update documentation and add regression tests for new behavior
|
|
875
|
+
4. Run `bundle exec rubocop -A` to fix style issues
|
|
876
|
+
5. Add YARD documentation for new public methods
|
|
877
|
+
6. Open a PR describing the motivation and changes
|
|
878
|
+
|
|
879
|
+
## License
|
|
880
|
+
|
|
881
|
+
TraceBook is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|