tracebook 0.1.1 → 1.0.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 +4 -4
- data/CHANGELOG.md +49 -24
- data/README.md +197 -713
- data/app/assets/javascripts/tracebook/application.js +92 -35
- data/app/assets/stylesheets/tracebook/application.css +1882 -55
- data/app/controllers/tracebook/application_controller.rb +25 -0
- data/app/controllers/tracebook/chats_controller.rb +229 -0
- data/app/controllers/tracebook/comments_controller.rb +25 -0
- data/app/helpers/tracebook/chats_helper.rb +29 -0
- data/app/models/tracebook/chat_review.rb +19 -0
- data/app/models/tracebook/comment.rb +14 -0
- data/app/models/tracebook/message_cost.rb +12 -0
- data/app/models/tracebook/pricing_rule.rb +6 -8
- data/app/views/tracebook/chats/index.html.erb +77 -0
- data/app/views/tracebook/chats/show.html.erb +94 -0
- data/config/routes.rb +6 -6
- data/db/migrate/20260325000100_create_tracebook_message_costs.rb +19 -0
- data/db/migrate/20260325000200_create_tracebook_chat_reviews.rb +19 -0
- data/db/migrate/{20241112000300_create_tracebook_pricing_rules.rb → 20260325000300_create_tracebook_pricing_rules.rb} +3 -3
- data/db/migrate/20260325000500_create_tracebook_comments.rb +15 -0
- data/lib/generators/tracebook/install/install_generator.rb +6 -9
- data/lib/generators/tracebook/install/templates/initializer.rb.tt +11 -5
- data/lib/tasks/tracebook_tasks.rake +14 -4
- data/lib/tracebook/adapters/ruby_llm.rb +19 -81
- data/lib/tracebook/adapters.rb +5 -4
- data/lib/tracebook/config.rb +83 -104
- data/lib/tracebook/engine.rb +6 -0
- data/lib/tracebook/errors.rb +0 -2
- data/lib/tracebook/pricing/calculator.rb +11 -6
- data/lib/tracebook/pricing.rb +0 -2
- data/lib/tracebook/redaction/pattern.rb +124 -0
- data/lib/tracebook/redaction/pipeline.rb +32 -0
- data/lib/tracebook/seeds/pricing_rules.rb +62 -0
- data/lib/tracebook/version.rb +1 -1
- data/lib/tracebook.rb +46 -152
- metadata +23 -51
- data/app/controllers/tracebook/exports_controller.rb +0 -25
- data/app/controllers/tracebook/interactions_controller.rb +0 -71
- data/app/helpers/tracebook/interactions_helper.rb +0 -35
- data/app/jobs/tracebook/daily_rollups_job.rb +0 -100
- data/app/jobs/tracebook/export_job.rb +0 -162
- data/app/jobs/tracebook/persist_interaction_job.rb +0 -160
- data/app/mailers/tracebook/application_mailer.rb +0 -6
- data/app/models/tracebook/interaction.rb +0 -103
- data/app/models/tracebook/redaction_rule.rb +0 -81
- data/app/models/tracebook/rollup_daily.rb +0 -73
- data/app/views/tracebook/interactions/index.html.erb +0 -108
- data/app/views/tracebook/interactions/show.html.erb +0 -44
- data/db/migrate/20241112000100_create_tracebook_interactions.rb +0 -55
- data/db/migrate/20241112000200_create_tracebook_rollups_dailies.rb +0 -24
- data/db/migrate/20241112000400_create_tracebook_redaction_rules.rb +0 -19
- data/lib/tracebook/adapters/active_agent.rb +0 -82
- data/lib/tracebook/mappers/anthropic.rb +0 -59
- data/lib/tracebook/mappers/base.rb +0 -38
- data/lib/tracebook/mappers/ollama.rb +0 -49
- data/lib/tracebook/mappers/openai.rb +0 -75
- data/lib/tracebook/mappers.rb +0 -283
- data/lib/tracebook/normalized_interaction.rb +0 -86
- data/lib/tracebook/redaction_pipeline.rb +0 -88
- data/lib/tracebook/redactors/base.rb +0 -29
- data/lib/tracebook/redactors/card_pan.rb +0 -15
- data/lib/tracebook/redactors/email.rb +0 -15
- data/lib/tracebook/redactors/phone.rb +0 -15
- data/lib/tracebook/redactors.rb +0 -8
- data/lib/tracebook/result.rb +0 -53
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Tracebook
|
|
4
|
-
# Background job for aggregating daily metrics.
|
|
5
|
-
#
|
|
6
|
-
# Summarizes interactions by date/provider/model/project into {RollupDaily}
|
|
7
|
-
# records for analytics and cost reporting. Should be scheduled nightly for
|
|
8
|
-
# each active provider/model combination.
|
|
9
|
-
#
|
|
10
|
-
# ## Aggregated Metrics
|
|
11
|
-
# - Total interaction count
|
|
12
|
-
# - Success/error counts
|
|
13
|
-
# - Input/output token sums
|
|
14
|
-
# - Total cost in cents
|
|
15
|
-
#
|
|
16
|
-
# @example Schedule with Sidekiq Cron
|
|
17
|
-
# Sidekiq::Cron::Job.create(
|
|
18
|
-
# name: "TraceBook OpenAI rollups",
|
|
19
|
-
# cron: "0 2 * * *",
|
|
20
|
-
# class: "Tracebook::DailyRollupsJob",
|
|
21
|
-
# kwargs: { date: Date.yesterday, provider: "openai", model: nil, project: nil }
|
|
22
|
-
# )
|
|
23
|
-
#
|
|
24
|
-
# @example Run manually for specific date/model
|
|
25
|
-
# DailyRollupsJob.perform_now(
|
|
26
|
-
# date: Date.yesterday,
|
|
27
|
-
# provider: "openai",
|
|
28
|
-
# model: "gpt-4o",
|
|
29
|
-
# project: "support"
|
|
30
|
-
# )
|
|
31
|
-
#
|
|
32
|
-
# @see RollupDaily
|
|
33
|
-
class DailyRollupsJob < ApplicationJob
|
|
34
|
-
# Aggregates metrics for a specific date/provider/model/project.
|
|
35
|
-
#
|
|
36
|
-
# Creates or updates a {RollupDaily} record with summarized statistics.
|
|
37
|
-
#
|
|
38
|
-
# @param date [Date] Date to aggregate (usually Date.yesterday)
|
|
39
|
-
# @param provider [String] Provider name (e.g., "openai")
|
|
40
|
-
# @param model [String, nil] Model identifier (nil for all models)
|
|
41
|
-
# @param project [String, nil] Project name (nil for all projects)
|
|
42
|
-
#
|
|
43
|
-
# @return [void]
|
|
44
|
-
#
|
|
45
|
-
# @raise [ActiveRecord::RecordInvalid] if rollup fails validation
|
|
46
|
-
def perform(date:, provider:, model:, project: nil)
|
|
47
|
-
scope = Interaction.where(provider: provider, model: model)
|
|
48
|
-
scope = scope.where(project: project) if project
|
|
49
|
-
scope = scope.where(created_at: date.beginning_of_day..date.end_of_day)
|
|
50
|
-
|
|
51
|
-
counts = normalize_status_counts(scope.group(:status).count)
|
|
52
|
-
tokens = scope.pluck(Arel.sql("COALESCE(input_tokens, 0)"), Arel.sql("COALESCE(output_tokens, 0)"))
|
|
53
|
-
costs = scope.pluck(Arel.sql("COALESCE(cost_total_cents, 0)"))
|
|
54
|
-
|
|
55
|
-
input_sum = tokens.sum { |(input, _)| input.to_i }
|
|
56
|
-
output_sum = tokens.sum { |(_, output)| output.to_i }
|
|
57
|
-
cost_sum = costs.sum(&:to_i)
|
|
58
|
-
|
|
59
|
-
rollup = RollupDaily.find_or_initialize_by(date: date, provider: provider, model: model, project: project)
|
|
60
|
-
rollup.interactions_count = scope.count
|
|
61
|
-
rollup.success_count = counts.fetch("success", 0)
|
|
62
|
-
rollup.error_count = counts.fetch("error", 0)
|
|
63
|
-
rollup.input_tokens_sum = input_sum
|
|
64
|
-
rollup.output_tokens_sum = output_sum
|
|
65
|
-
rollup.cost_cents_sum = cost_sum
|
|
66
|
-
rollup.currency = determine_currency(scope) || rollup.currency
|
|
67
|
-
rollup.save!
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
private
|
|
71
|
-
|
|
72
|
-
def normalize_status_counts(counts)
|
|
73
|
-
counts.each_with_object(Hash.new(0)) do |(raw_key, count), normalized|
|
|
74
|
-
status_name = status_name_for(raw_key)
|
|
75
|
-
next unless status_name
|
|
76
|
-
|
|
77
|
-
normalized[status_name] += count
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def status_name_for(raw_key)
|
|
82
|
-
key_string = raw_key.to_s
|
|
83
|
-
return key_string if Interaction.statuses.key?(key_string)
|
|
84
|
-
|
|
85
|
-
integer_string?(key_string) ? Interaction.statuses.invert[key_string.to_i] : nil
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def integer_string?(value)
|
|
89
|
-
value.match?(/\A-?\d+\z/)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def determine_currency(scope)
|
|
93
|
-
scope.pick(:currency)
|
|
94
|
-
rescue NoMethodError
|
|
95
|
-
scope.first&.currency
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
TraceBook = Tracebook unless defined?(TraceBook)
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "csv"
|
|
4
|
-
|
|
5
|
-
module Tracebook
|
|
6
|
-
# Background job for exporting interactions to CSV or NDJSON.
|
|
7
|
-
#
|
|
8
|
-
# Streams filtered interactions to an export file stored in ActiveStorage.
|
|
9
|
-
# Supports filtering by provider, model, project, date range, tags, etc.
|
|
10
|
-
#
|
|
11
|
-
# ## Supported Formats
|
|
12
|
-
# - **CSV** - Comma-separated values with headers
|
|
13
|
-
# - **NDJSON** - Newline-delimited JSON (one interaction per line)
|
|
14
|
-
#
|
|
15
|
-
# ## Exported Fields
|
|
16
|
-
# - timestamp, project, provider, model, status
|
|
17
|
-
# - input_tokens, output_tokens, cost_total_cents
|
|
18
|
-
# - tags (pipe-separated)
|
|
19
|
-
# - request_payload, response_payload (JSON)
|
|
20
|
-
# - metadata
|
|
21
|
-
#
|
|
22
|
-
# @example Enqueue export job
|
|
23
|
-
# blob = ExportJob.perform_now(
|
|
24
|
-
# format: :csv,
|
|
25
|
-
# filters: {
|
|
26
|
-
# provider: "openai",
|
|
27
|
-
# from: 30.days.ago,
|
|
28
|
-
# to: Date.current
|
|
29
|
-
# }
|
|
30
|
-
# )
|
|
31
|
-
# download_url = Rails.application.routes.url_helpers.rails_blob_url(blob)
|
|
32
|
-
#
|
|
33
|
-
# @example Export specific project
|
|
34
|
-
# ExportJob.perform_later(
|
|
35
|
-
# format: :ndjson,
|
|
36
|
-
# filters: { project: "support", review_state: "approved" }
|
|
37
|
-
# )
|
|
38
|
-
#
|
|
39
|
-
# @see Interaction.filtered
|
|
40
|
-
class ExportJob < ApplicationJob
|
|
41
|
-
# Exports filtered interactions to specified format.
|
|
42
|
-
#
|
|
43
|
-
# @param format [Symbol, String] Export format (:csv or :ndjson)
|
|
44
|
-
# @param filters [Hash] Filters to apply (see {Interaction.filtered})
|
|
45
|
-
#
|
|
46
|
-
# @option filters [String] :provider Provider name
|
|
47
|
-
# @option filters [String] :model Model identifier
|
|
48
|
-
# @option filters [String] :project Project name
|
|
49
|
-
# @option filters [Symbol, String] :status Status filter
|
|
50
|
-
# @option filters [Symbol, String] :review_state Review state filter
|
|
51
|
-
# @option filters [String] :tag Tag to filter by
|
|
52
|
-
# @option filters [Date, String] :from Start date
|
|
53
|
-
# @option filters [Date, String] :to End date
|
|
54
|
-
#
|
|
55
|
-
# @return [ActiveStorage::Blob] The created export blob
|
|
56
|
-
#
|
|
57
|
-
# @raise [ArgumentError] if format is not supported
|
|
58
|
-
def perform(format:, filters: {})
|
|
59
|
-
interactions = Interaction.filtered(filters).order(:created_at)
|
|
60
|
-
data = export_as(interactions, format.to_sym)
|
|
61
|
-
create_blob(data, format)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
private
|
|
65
|
-
|
|
66
|
-
def export_as(interactions, format)
|
|
67
|
-
case format
|
|
68
|
-
when :csv
|
|
69
|
-
csv_for(interactions)
|
|
70
|
-
when :ndjson
|
|
71
|
-
ndjson_for(interactions)
|
|
72
|
-
else
|
|
73
|
-
raise ArgumentError, "Unsupported export format: #{format}"
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def csv_for(interactions)
|
|
78
|
-
CSV.generate do |csv|
|
|
79
|
-
csv << csv_headers
|
|
80
|
-
interactions.find_each do |interaction|
|
|
81
|
-
csv << serialize_interaction(interaction).values_at(*csv_headers)
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def ndjson_for(interactions)
|
|
87
|
-
Enumerator.new do |yielder|
|
|
88
|
-
interactions.find_each do |interaction|
|
|
89
|
-
yielder << serialize_interaction(interaction).to_json
|
|
90
|
-
end
|
|
91
|
-
end.to_a.join("\n")
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def serialize_interaction(interaction)
|
|
95
|
-
payload = {
|
|
96
|
-
"timestamp" => interaction.created_at.iso8601,
|
|
97
|
-
"project" => interaction.project,
|
|
98
|
-
"provider" => interaction.provider,
|
|
99
|
-
"model" => interaction.model,
|
|
100
|
-
"status" => interaction.status,
|
|
101
|
-
"input_tokens" => interaction.input_tokens,
|
|
102
|
-
"output_tokens" => interaction.output_tokens,
|
|
103
|
-
"cost_total_cents" => interaction.cost_total_cents,
|
|
104
|
-
"tags" => Array(interaction.tags).join("|"),
|
|
105
|
-
"request_payload" => load_payload(interaction, :request),
|
|
106
|
-
"response_payload" => load_payload(interaction, :response)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
payload.merge("metadata" => interaction.metadata)
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def load_payload(interaction, type)
|
|
113
|
-
store = interaction.public_send("#{type}_payload_store")
|
|
114
|
-
if store == "active_storage"
|
|
115
|
-
blob = interaction.public_send("#{type}_payload_blob")
|
|
116
|
-
return JSON.parse(blob.download) if blob
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
interaction.public_send("#{type}_payload")
|
|
120
|
-
rescue JSON::ParserError
|
|
121
|
-
interaction.public_send("#{type}_payload")
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def create_blob(data, format)
|
|
125
|
-
ActiveStorage::Blob.create_and_upload!(
|
|
126
|
-
io: StringIO.new(data),
|
|
127
|
-
filename: "tracebook-export-#{Time.current.to_i}.#{format}",
|
|
128
|
-
content_type: content_type_for(format)
|
|
129
|
-
)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def content_type_for(format)
|
|
133
|
-
case format.to_sym
|
|
134
|
-
when :csv
|
|
135
|
-
"text/csv"
|
|
136
|
-
when :ndjson
|
|
137
|
-
"application/x-ndjson"
|
|
138
|
-
else
|
|
139
|
-
"application/octet-stream"
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def csv_headers
|
|
144
|
-
@csv_headers ||= [
|
|
145
|
-
"timestamp",
|
|
146
|
-
"project",
|
|
147
|
-
"provider",
|
|
148
|
-
"model",
|
|
149
|
-
"status",
|
|
150
|
-
"input_tokens",
|
|
151
|
-
"output_tokens",
|
|
152
|
-
"cost_total_cents",
|
|
153
|
-
"tags",
|
|
154
|
-
"request_payload",
|
|
155
|
-
"response_payload",
|
|
156
|
-
"metadata"
|
|
157
|
-
]
|
|
158
|
-
end
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
TraceBook = Tracebook unless defined?(TraceBook)
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "stringio"
|
|
5
|
-
require "securerandom"
|
|
6
|
-
require "time"
|
|
7
|
-
|
|
8
|
-
module Tracebook
|
|
9
|
-
# Background job for persisting LLM interactions.
|
|
10
|
-
#
|
|
11
|
-
# Handles the complete persistence pipeline:
|
|
12
|
-
# 1. Deserialize payload
|
|
13
|
-
# 2. Apply PII redaction
|
|
14
|
-
# 3. Calculate cost based on pricing rules
|
|
15
|
-
# 4. Store interaction in database (with encryption)
|
|
16
|
-
# 5. Handle large payloads via ActiveStorage
|
|
17
|
-
# 6. Enqueue rollup job for analytics
|
|
18
|
-
#
|
|
19
|
-
# This job is enqueued by {Tracebook.record!} when `config.persist_async` is true.
|
|
20
|
-
#
|
|
21
|
-
# @example Enqueuing directly
|
|
22
|
-
# PersistInteractionJob.perform_later(
|
|
23
|
-
# provider: "openai",
|
|
24
|
-
# model: "gpt-4o",
|
|
25
|
-
# request_payload: { messages: messages },
|
|
26
|
-
# response_payload: response,
|
|
27
|
-
# input_tokens: 100,
|
|
28
|
-
# output_tokens: 50
|
|
29
|
-
# )
|
|
30
|
-
#
|
|
31
|
-
# @example Synchronous execution (testing)
|
|
32
|
-
# interaction = PersistInteractionJob.perform_now(payload)
|
|
33
|
-
#
|
|
34
|
-
# @see Tracebook.record!
|
|
35
|
-
# @see RedactionPipeline
|
|
36
|
-
# @see Pricing::Calculator
|
|
37
|
-
class PersistInteractionJob < ApplicationJob
|
|
38
|
-
# Default inline payload size threshold (64KB)
|
|
39
|
-
INLINE_THRESHOLD_BYTES = 64 * 1024
|
|
40
|
-
|
|
41
|
-
# Processes and persists an LLM interaction.
|
|
42
|
-
#
|
|
43
|
-
# @param payload [Hash] Normalized interaction attributes
|
|
44
|
-
# @return [Interaction] The persisted interaction record
|
|
45
|
-
#
|
|
46
|
-
# @raise [ActiveRecord::RecordInvalid] if interaction fails validation
|
|
47
|
-
def perform(payload)
|
|
48
|
-
normalized = from_payload(payload)
|
|
49
|
-
redacted = redaction_pipeline.call(normalized)
|
|
50
|
-
cost = Pricing::Calculator.call(
|
|
51
|
-
provider: redacted.provider,
|
|
52
|
-
model: redacted.model,
|
|
53
|
-
input_tokens: redacted.input_tokens,
|
|
54
|
-
output_tokens: redacted.output_tokens,
|
|
55
|
-
occurred_at: occurred_at(redacted)
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
interaction = persist_interaction(redacted, cost)
|
|
59
|
-
enqueue_rollup(interaction)
|
|
60
|
-
interaction
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
private
|
|
64
|
-
|
|
65
|
-
def from_payload(payload)
|
|
66
|
-
attributes = payload.to_h.deep_symbolize_keys
|
|
67
|
-
NormalizedInteraction.new(**attributes)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def persist_interaction(normalized, cost)
|
|
71
|
-
attributes = {
|
|
72
|
-
provider: normalized.provider,
|
|
73
|
-
model: normalized.model,
|
|
74
|
-
project: normalized.project,
|
|
75
|
-
request_text: normalized.request_text,
|
|
76
|
-
response_text: normalized.response_text,
|
|
77
|
-
input_tokens: normalized.input_tokens,
|
|
78
|
-
output_tokens: normalized.output_tokens,
|
|
79
|
-
total_tokens: total_tokens(normalized),
|
|
80
|
-
latency_ms: normalized.latency_ms,
|
|
81
|
-
status: normalized.status,
|
|
82
|
-
error_class: normalized.error_class,
|
|
83
|
-
error_message: normalized.error_message,
|
|
84
|
-
tags: normalized.tags,
|
|
85
|
-
metadata: normalized.metadata,
|
|
86
|
-
parent_id: normalized.parent_id,
|
|
87
|
-
session_id: normalized.session_id,
|
|
88
|
-
cost_input_cents: cost.input_cents,
|
|
89
|
-
cost_output_cents: cost.output_cents,
|
|
90
|
-
cost_total_cents: cost.total_cents,
|
|
91
|
-
currency: cost.currency || Tracebook.config.default_currency
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
ActiveRecord::Base.transaction do
|
|
95
|
-
Interaction.create!(attributes).tap do |interaction|
|
|
96
|
-
interaction.user = normalized.user if normalized.user
|
|
97
|
-
persist_payloads(interaction, normalized)
|
|
98
|
-
interaction.save! if interaction.changed?
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def total_tokens(normalized)
|
|
104
|
-
[ normalized.input_tokens.to_i, normalized.output_tokens.to_i ].compact.sum
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def persist_payloads(interaction, normalized)
|
|
108
|
-
store_payload(:request, interaction, normalized.request_payload)
|
|
109
|
-
store_payload(:response, interaction, normalized.response_payload)
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def store_payload(type, interaction, payload)
|
|
113
|
-
return if payload.nil?
|
|
114
|
-
|
|
115
|
-
serialized = JSON.generate(payload)
|
|
116
|
-
if serialized.bytesize > inline_threshold
|
|
117
|
-
blob = create_blob(serialized, "#{type}-payload")
|
|
118
|
-
interaction.public_send("#{type}_payload_store=", "active_storage")
|
|
119
|
-
interaction.public_send("#{type}_payload_blob_id=", blob.id)
|
|
120
|
-
interaction.public_send("#{type}_payload=", nil)
|
|
121
|
-
else
|
|
122
|
-
interaction.public_send("#{type}_payload_store=", "inline")
|
|
123
|
-
interaction.public_send("#{type}_payload=", payload)
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def inline_threshold
|
|
128
|
-
Tracebook.config.inline_payload_bytes
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def create_blob(contents, label)
|
|
132
|
-
ActiveStorage::Blob.create_and_upload!(
|
|
133
|
-
io: StringIO.new(contents),
|
|
134
|
-
filename: "tracebook-#{label}-#{SecureRandom.uuid}.json",
|
|
135
|
-
content_type: "application/json"
|
|
136
|
-
)
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def enqueue_rollup(interaction)
|
|
140
|
-
DailyRollupsJob.perform_later(
|
|
141
|
-
date: interaction.created_at.to_date,
|
|
142
|
-
provider: interaction.provider,
|
|
143
|
-
model: interaction.model,
|
|
144
|
-
project: interaction.project
|
|
145
|
-
)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def redaction_pipeline
|
|
149
|
-
@redaction_pipeline ||= RedactionPipeline.new(config: Tracebook.config)
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def occurred_at(normalized)
|
|
153
|
-
normalized.metadata && normalized.metadata["timestamp"] ? Time.parse(normalized.metadata["timestamp"].to_s) : Time.current
|
|
154
|
-
rescue ArgumentError
|
|
155
|
-
Time.current
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
TraceBook = Tracebook unless defined?(TraceBook)
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Tracebook
|
|
4
|
-
# ActiveRecord model for LLM interactions.
|
|
5
|
-
#
|
|
6
|
-
# Stores all data related to an LLM API call including request/response payloads,
|
|
7
|
-
# usage metrics, cost, review state, and relationships.
|
|
8
|
-
#
|
|
9
|
-
# ## Payload Fields
|
|
10
|
-
# - `request_payload` - Full request sent to provider (JSON)
|
|
11
|
-
# - `response_payload` - Full response from provider (JSON)
|
|
12
|
-
# - `review_comment` - Reviewer's comments
|
|
13
|
-
#
|
|
14
|
-
# Note: Encryption is optional. See README for instructions on enabling
|
|
15
|
-
# ActiveRecord::Encryption for these fields.
|
|
16
|
-
#
|
|
17
|
-
# ## Enums
|
|
18
|
-
# - `status`: `:success`, `:error`, `:canceled`
|
|
19
|
-
# - `review_state`: `:pending`, `:approved`, `:flagged`, `:rejected`
|
|
20
|
-
#
|
|
21
|
-
# ## Associations
|
|
22
|
-
# - `parent` - Parent interaction for hierarchical chains
|
|
23
|
-
# - `user` - Polymorphic association to user who triggered the call
|
|
24
|
-
# - `request_payload_blob` - ActiveStorage blob for large requests
|
|
25
|
-
# - `response_payload_blob` - ActiveStorage blob for large responses
|
|
26
|
-
#
|
|
27
|
-
# @example Finding interactions
|
|
28
|
-
# Interaction.by_provider("openai")
|
|
29
|
-
# .by_model("gpt-4o")
|
|
30
|
-
# .by_review_state(:pending)
|
|
31
|
-
# .between_dates(30.days.ago, Date.current)
|
|
32
|
-
#
|
|
33
|
-
# @example Filtering with parameters
|
|
34
|
-
# Interaction.filtered(
|
|
35
|
-
# provider: "anthropic",
|
|
36
|
-
# model: "claude-3-5-sonnet",
|
|
37
|
-
# review_state: "flagged",
|
|
38
|
-
# from: "2025-01-01",
|
|
39
|
-
# to: "2025-01-31"
|
|
40
|
-
# )
|
|
41
|
-
class Interaction < ApplicationRecord
|
|
42
|
-
self.table_name = "tracebook_interactions"
|
|
43
|
-
|
|
44
|
-
# @!attribute [rw] parent
|
|
45
|
-
# @return [Tracebook::Interaction, nil] Parent interaction for hierarchical chains
|
|
46
|
-
belongs_to :parent, class_name: "Tracebook::Interaction", optional: true
|
|
47
|
-
|
|
48
|
-
# @!attribute [rw] user
|
|
49
|
-
# @return [ActiveRecord::Base, nil] Polymorphic user who triggered this interaction
|
|
50
|
-
belongs_to :user, polymorphic: true, optional: true
|
|
51
|
-
|
|
52
|
-
# @!attribute [rw] request_payload_blob
|
|
53
|
-
# @return [ActiveStorage::Blob, nil] Blob for large request payloads
|
|
54
|
-
belongs_to :request_payload_blob, class_name: "ActiveStorage::Blob", optional: true
|
|
55
|
-
|
|
56
|
-
# @!attribute [rw] response_payload_blob
|
|
57
|
-
# @return [ActiveStorage::Blob, nil] Blob for large response payloads
|
|
58
|
-
belongs_to :response_payload_blob, class_name: "ActiveStorage::Blob", optional: true
|
|
59
|
-
|
|
60
|
-
# @!attribute [rw] status
|
|
61
|
-
# @return [Symbol] Request status (:success, :error, :canceled)
|
|
62
|
-
enum :status, { success: 0, error: 1, canceled: 2 }, prefix: true
|
|
63
|
-
|
|
64
|
-
# @!attribute [rw] review_state
|
|
65
|
-
# @return [Symbol] Review state (:pending, :approved, :flagged, :rejected)
|
|
66
|
-
enum :review_state, { pending: 0, approved: 1, flagged: 2, rejected: 3 }, prefix: true
|
|
67
|
-
|
|
68
|
-
attribute :tags, :json, default: []
|
|
69
|
-
attribute :metadata, :json, default: {}
|
|
70
|
-
attribute :request_payload, :json, default: {}
|
|
71
|
-
attribute :response_payload, :json, default: {}
|
|
72
|
-
|
|
73
|
-
validates :provider, presence: true
|
|
74
|
-
validates :model, presence: true
|
|
75
|
-
|
|
76
|
-
scope :by_provider, ->(provider) { where(provider: provider) if provider.present? }
|
|
77
|
-
scope :by_model, ->(model) { where(model: model) if model.present? }
|
|
78
|
-
scope :by_project, ->(project) { where(project: project) if project.present? }
|
|
79
|
-
scope :by_status, ->(status) { where(status: status) if status.present? }
|
|
80
|
-
scope :by_review_state, ->(state) { where(review_state: state) if state.present? }
|
|
81
|
-
scope :between_dates, ->(from, to) {
|
|
82
|
-
scope = all
|
|
83
|
-
scope = scope.where("created_at >= ?", Date.parse(from.to_s).beginning_of_day) if from.present?
|
|
84
|
-
scope = scope.where("created_at <= ?", Date.parse(to.to_s).end_of_day) if to.present?
|
|
85
|
-
scope
|
|
86
|
-
}
|
|
87
|
-
scope :tagged_with, ->(tag) {
|
|
88
|
-
where("tags LIKE ?", "%#{sanitize_sql_like(tag)}%") if tag.present?
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
def self.filtered(params)
|
|
92
|
-
by_provider(params[:provider])
|
|
93
|
-
.by_model(params[:model])
|
|
94
|
-
.by_project(params[:project])
|
|
95
|
-
.by_status(params[:status])
|
|
96
|
-
.by_review_state(params[:review_state])
|
|
97
|
-
.tagged_with(params[:tag])
|
|
98
|
-
.between_dates(params[:from], params[:to])
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
TraceBook = Tracebook unless defined?(TraceBook)
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Tracebook
|
|
4
|
-
# Rule for redacting PII from interaction payloads.
|
|
5
|
-
#
|
|
6
|
-
# Defines a regex pattern to detect and replace sensitive data before
|
|
7
|
-
# persistence. Runs on request, response, or both payloads.
|
|
8
|
-
#
|
|
9
|
-
# ## Fields
|
|
10
|
-
# - `name` - Human-readable name for this rule
|
|
11
|
-
# - `pattern` - Regular expression pattern to match
|
|
12
|
-
# - `replacement` - Replacement string (e.g., "[REDACTED]", "[EMAIL]")
|
|
13
|
-
# - `applies_to` - Where to apply: `:request`, `:response`, `:both`, `:metadata`
|
|
14
|
-
# - `enabled` - Whether this rule is active
|
|
15
|
-
#
|
|
16
|
-
# ## Built-in Rules
|
|
17
|
-
# TraceBook includes default redactors for:
|
|
18
|
-
# - Email addresses
|
|
19
|
-
# - Phone numbers (US format)
|
|
20
|
-
# - Credit card PANs
|
|
21
|
-
#
|
|
22
|
-
# @example Creating a custom redaction rule
|
|
23
|
-
# RedactionRule.create!(
|
|
24
|
-
# name: "API Keys",
|
|
25
|
-
# pattern: 'api_key["\s]*[:=]["\s]*\K[\w-]+',
|
|
26
|
-
# replacement: "[API_KEY]",
|
|
27
|
-
# applies_to: :both,
|
|
28
|
-
# enabled: true
|
|
29
|
-
# )
|
|
30
|
-
#
|
|
31
|
-
# @example Email redaction
|
|
32
|
-
# RedactionRule.create!(
|
|
33
|
-
# name: "Email Addresses",
|
|
34
|
-
# pattern: '\b[\w\.-]+@[\w\.-]+\.\w{2,}\b',
|
|
35
|
-
# replacement: "[EMAIL]",
|
|
36
|
-
# applies_to: :both,
|
|
37
|
-
# enabled: true
|
|
38
|
-
# )
|
|
39
|
-
#
|
|
40
|
-
# @example SSN redaction
|
|
41
|
-
# RedactionRule.create!(
|
|
42
|
-
# name: "Social Security Numbers",
|
|
43
|
-
# pattern: '\b\d{3}-\d{2}-\d{4}\b',
|
|
44
|
-
# replacement: "[SSN]",
|
|
45
|
-
# applies_to: :both,
|
|
46
|
-
# enabled: true
|
|
47
|
-
# )
|
|
48
|
-
#
|
|
49
|
-
# @see Redactors::Email
|
|
50
|
-
# @see Redactors::Phone
|
|
51
|
-
# @see Redactors::CardPAN
|
|
52
|
-
class RedactionRule < ApplicationRecord
|
|
53
|
-
self.table_name = "tracebook_redaction_rules"
|
|
54
|
-
|
|
55
|
-
# @!attribute [rw] applies_to
|
|
56
|
-
# @return [Symbol] Where to apply redaction (:request, :response, :both, :metadata)
|
|
57
|
-
enum :applies_to, { request: 0, response: 1, both: 2, metadata: 3 }
|
|
58
|
-
|
|
59
|
-
validates :name, presence: true
|
|
60
|
-
validates :pattern, presence: true
|
|
61
|
-
validates :replacement, presence: true
|
|
62
|
-
|
|
63
|
-
# Returns the compiled regex pattern.
|
|
64
|
-
#
|
|
65
|
-
# Caches the compiled pattern for performance. If pattern is invalid,
|
|
66
|
-
# falls back to escaped literal match.
|
|
67
|
-
#
|
|
68
|
-
# @return [Regexp] Compiled regular expression with MULTILINE flag
|
|
69
|
-
#
|
|
70
|
-
# @example
|
|
71
|
-
# rule = RedactionRule.new(pattern: '\b\d{3}-\d{2}-\d{4}\b')
|
|
72
|
-
# rule.compiled_pattern.match("123-45-6789") # => MatchData
|
|
73
|
-
def compiled_pattern
|
|
74
|
-
@compiled_pattern ||= Regexp.new(pattern, Regexp::MULTILINE)
|
|
75
|
-
rescue RegexpError
|
|
76
|
-
Regexp.new(Regexp.escape(pattern.to_s))
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
TraceBook = Tracebook unless defined?(TraceBook)
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Tracebook
|
|
4
|
-
# Daily aggregated metrics for LLM interactions.
|
|
5
|
-
#
|
|
6
|
-
# Stores summarized statistics by date/provider/model/project for analytics
|
|
7
|
-
# and cost reporting. Updated nightly by {DailyRollupsJob}.
|
|
8
|
-
#
|
|
9
|
-
# ## Fields
|
|
10
|
-
# - `date` - Date of rollup (part of composite PK)
|
|
11
|
-
# - `provider` - Provider name (part of composite PK)
|
|
12
|
-
# - `model` - Model identifier (part of composite PK)
|
|
13
|
-
# - `project` - Project name (part of composite PK, nullable)
|
|
14
|
-
# - `interactions_count` - Total number of interactions
|
|
15
|
-
# - `success_count` - Number of successful interactions
|
|
16
|
-
# - `error_count` - Number of failed interactions
|
|
17
|
-
# - `input_tokens_sum` - Sum of input tokens
|
|
18
|
-
# - `output_tokens_sum` - Sum of output tokens
|
|
19
|
-
# - `cost_cents_sum` - Total cost in cents
|
|
20
|
-
#
|
|
21
|
-
# ## Primary Key
|
|
22
|
-
# Composite PK: `(date, provider, model, project)`
|
|
23
|
-
#
|
|
24
|
-
# @example Querying daily metrics
|
|
25
|
-
# RollupDaily
|
|
26
|
-
# .where(provider: "openai", date: Date.current)
|
|
27
|
-
# .sum(:cost_cents_sum) / 100.0 # Total cost in dollars
|
|
28
|
-
#
|
|
29
|
-
# @example Finding top models by cost
|
|
30
|
-
# RollupDaily
|
|
31
|
-
# .where(date: 30.days.ago..Date.current)
|
|
32
|
-
# .group(:provider, :model)
|
|
33
|
-
# .sum(:cost_cents_sum)
|
|
34
|
-
# .sort_by { |_, cents| -cents }
|
|
35
|
-
# .first(10)
|
|
36
|
-
#
|
|
37
|
-
# @example Error rate for a model
|
|
38
|
-
# rollup = RollupDaily.find_by(date: Date.current, provider: "openai", model: "gpt-4o")
|
|
39
|
-
# error_rate = rollup.error_count.to_f / rollup.interactions_count
|
|
40
|
-
#
|
|
41
|
-
# @see DailyRollupsJob
|
|
42
|
-
class RollupDaily < ApplicationRecord
|
|
43
|
-
self.table_name = "tracebook_rollups_dailies"
|
|
44
|
-
|
|
45
|
-
# @!attribute [rw] interactions_count
|
|
46
|
-
# @return [Integer] Total number of interactions (default: 0)
|
|
47
|
-
attribute :interactions_count, :integer, default: 0
|
|
48
|
-
|
|
49
|
-
# @!attribute [rw] success_count
|
|
50
|
-
# @return [Integer] Number of successful interactions (default: 0)
|
|
51
|
-
attribute :success_count, :integer, default: 0
|
|
52
|
-
|
|
53
|
-
# @!attribute [rw] error_count
|
|
54
|
-
# @return [Integer] Number of failed interactions (default: 0)
|
|
55
|
-
attribute :error_count, :integer, default: 0
|
|
56
|
-
|
|
57
|
-
# @!attribute [rw] input_tokens_sum
|
|
58
|
-
# @return [Integer] Sum of input tokens across all interactions (default: 0)
|
|
59
|
-
attribute :input_tokens_sum, :integer, default: 0
|
|
60
|
-
|
|
61
|
-
# @!attribute [rw] output_tokens_sum
|
|
62
|
-
# @return [Integer] Sum of output tokens across all interactions (default: 0)
|
|
63
|
-
attribute :output_tokens_sum, :integer, default: 0
|
|
64
|
-
|
|
65
|
-
# @!attribute [rw] cost_cents_sum
|
|
66
|
-
# @return [Integer] Total cost in cents (default: 0)
|
|
67
|
-
attribute :cost_cents_sum, :integer, default: 0
|
|
68
|
-
|
|
69
|
-
validates :date, presence: true
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
TraceBook = Tracebook unless defined?(TraceBook)
|