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/Rakefile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "bundler/setup"
|
|
2
|
+
|
|
3
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
|
4
|
+
load "rails/tasks/engine.rake"
|
|
5
|
+
|
|
6
|
+
require "bundler/gem_tasks"
|
|
7
|
+
|
|
8
|
+
require "rake/testtask"
|
|
9
|
+
|
|
10
|
+
Rake::TestTask.new(:test) do |t|
|
|
11
|
+
t.libs << "test"
|
|
12
|
+
t.pattern = "test/**/*_test.rb"
|
|
13
|
+
t.verbose = false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
task :prepare_test_db do
|
|
17
|
+
ENV["RAILS_ENV"] = "test"
|
|
18
|
+
Rake::Task["app:db:prepare"].invoke
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
task default: [ :prepare_test_db, :test ]
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
const STIMULUS_SRC = "https://unpkg.com/@hotwired/stimulus/dist/stimulus.umd.js";
|
|
3
|
+
let booted = false;
|
|
4
|
+
|
|
5
|
+
function warnStimulusMissing() {
|
|
6
|
+
if (booted) return;
|
|
7
|
+
|
|
8
|
+
console.warn("TraceBook: Stimulus failed to load; keyboard shortcuts disabled.");
|
|
9
|
+
if (document.body) {
|
|
10
|
+
const banner = document.createElement("div");
|
|
11
|
+
banner.className = "tb-alert";
|
|
12
|
+
banner.textContent = "Keyboard navigation is unavailable because Stimulus failed to load.";
|
|
13
|
+
document.body.prepend(banner);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function startApplication() {
|
|
18
|
+
if (booted || !window.Stimulus) {
|
|
19
|
+
warnStimulusMissing();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
booted = true;
|
|
24
|
+
const application = window.Stimulus.Application.start();
|
|
25
|
+
|
|
26
|
+
class KeyboardController extends window.Stimulus.Controller {
|
|
27
|
+
static get targets() {
|
|
28
|
+
return ["table", "row", "checkbox", "toggleAll", "reviewState"];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
connect() {
|
|
32
|
+
this.index = 0;
|
|
33
|
+
this.updateSelection();
|
|
34
|
+
this.element.addEventListener("keydown", this.handleKeydown.bind(this));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
handleKeydown(event) {
|
|
38
|
+
if (["j", "J"].includes(event.key)) {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
this.index = Math.min(this.rowTargets.length - 1, this.index + 1);
|
|
41
|
+
this.updateSelection();
|
|
42
|
+
}
|
|
43
|
+
if (["k", "K"].includes(event.key)) {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
this.index = Math.max(0, this.index - 1);
|
|
46
|
+
this.updateSelection();
|
|
47
|
+
}
|
|
48
|
+
if ([" ", "Enter"].includes(event.key)) {
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
const checkbox = this.checkboxTargets[this.index];
|
|
51
|
+
if (checkbox) {
|
|
52
|
+
checkbox.checked = !checkbox.checked;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
updateSelection() {
|
|
58
|
+
this.rowTargets.forEach((row, idx) => {
|
|
59
|
+
row.classList.toggle("tb-selected", idx === this.index);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
class JsonViewerController extends window.Stimulus.Controller {
|
|
65
|
+
static get targets() {
|
|
66
|
+
return ["content"];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
toggle() {
|
|
70
|
+
this.element.classList.toggle("tb-collapsed");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
application.register("keyboard", KeyboardController);
|
|
75
|
+
application.register("json-viewer", JsonViewerController);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (window.Stimulus) {
|
|
79
|
+
startApplication();
|
|
80
|
+
} else {
|
|
81
|
+
const script = document.createElement("script");
|
|
82
|
+
script.src = STIMULUS_SRC;
|
|
83
|
+
script.async = true;
|
|
84
|
+
script.onload = startApplication;
|
|
85
|
+
script.onerror = warnStimulusMissing;
|
|
86
|
+
document.head.appendChild(script);
|
|
87
|
+
}
|
|
88
|
+
})();
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
|
7
|
+
*
|
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
|
11
|
+
* It is generally better to create a new file per style scope.
|
|
12
|
+
*
|
|
13
|
+
*= require_tree .
|
|
14
|
+
*= require_self
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
:root {
|
|
18
|
+
color-scheme: light dark;
|
|
19
|
+
--tb-bg: #f9f9fb;
|
|
20
|
+
--tb-border: #d0d7de;
|
|
21
|
+
--tb-text: #202124;
|
|
22
|
+
--tb-accent: #0b5fff;
|
|
23
|
+
--tb-muted: #4a4d52;
|
|
24
|
+
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
body {
|
|
28
|
+
background: var(--tb-bg);
|
|
29
|
+
color: var(--tb-text);
|
|
30
|
+
margin: 0;
|
|
31
|
+
padding: 1.5rem;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.tb-container {
|
|
35
|
+
max-width: 1200px;
|
|
36
|
+
margin: 0 auto;
|
|
37
|
+
display: flex;
|
|
38
|
+
flex-direction: column;
|
|
39
|
+
gap: 1.5rem;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.tb-header h1 {
|
|
43
|
+
margin: 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.tb-kpis {
|
|
47
|
+
display: grid;
|
|
48
|
+
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
49
|
+
gap: 1rem;
|
|
50
|
+
margin-top: 1rem;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.tb-kpi {
|
|
54
|
+
padding: 0.75rem;
|
|
55
|
+
border: 1px solid var(--tb-border);
|
|
56
|
+
border-radius: 0.75rem;
|
|
57
|
+
background: white;
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
gap: 0.5rem;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.tb-kpi span {
|
|
64
|
+
font-size: 0.85rem;
|
|
65
|
+
color: var(--tb-muted);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.tb-kpi strong {
|
|
69
|
+
font-size: 1.25rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.tb-filter-form {
|
|
73
|
+
display: flex;
|
|
74
|
+
flex-direction: column;
|
|
75
|
+
gap: 1rem;
|
|
76
|
+
padding: 1rem;
|
|
77
|
+
border: 1px solid var(--tb-border);
|
|
78
|
+
border-radius: 0.75rem;
|
|
79
|
+
background: white;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.tb-filter-grid {
|
|
83
|
+
display: grid;
|
|
84
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
85
|
+
gap: 1rem;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.tb-filter-actions {
|
|
89
|
+
display: flex;
|
|
90
|
+
gap: 0.75rem;
|
|
91
|
+
align-items: center;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.tb-link {
|
|
95
|
+
color: var(--tb-accent);
|
|
96
|
+
text-decoration: none;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.tb-table-wrapper {
|
|
100
|
+
border: 1px solid var(--tb-border);
|
|
101
|
+
border-radius: 0.75rem;
|
|
102
|
+
background: white;
|
|
103
|
+
padding: 1rem;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.tb-table {
|
|
107
|
+
width: 100%;
|
|
108
|
+
border-collapse: collapse;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.tb-table th,
|
|
112
|
+
.tb-table td {
|
|
113
|
+
padding: 0.75rem;
|
|
114
|
+
border-bottom: 1px solid var(--tb-border);
|
|
115
|
+
text-align: left;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.tb-table tbody tr:hover {
|
|
119
|
+
background: rgba(11, 95, 255, 0.08);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.tb-selected {
|
|
123
|
+
outline: 2px solid var(--tb-accent);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.tb-collapsed {
|
|
127
|
+
max-height: 200px;
|
|
128
|
+
overflow: hidden;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.tb-alert {
|
|
132
|
+
background: #fffbdd;
|
|
133
|
+
border: 1px solid #f7d070;
|
|
134
|
+
color: #5c4400;
|
|
135
|
+
padding: 0.75rem 1rem;
|
|
136
|
+
border-radius: 0.5rem;
|
|
137
|
+
margin-bottom: 1rem;
|
|
138
|
+
font-size: 0.9rem;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.tb-section {
|
|
142
|
+
border: 1px solid var(--tb-border);
|
|
143
|
+
border-radius: 0.75rem;
|
|
144
|
+
background: white;
|
|
145
|
+
padding: 1rem;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.tb-meta-grid {
|
|
149
|
+
display: grid;
|
|
150
|
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
151
|
+
gap: 1rem;
|
|
152
|
+
margin-top: 1rem;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.tb-meta-grid span {
|
|
156
|
+
display: block;
|
|
157
|
+
font-size: 0.8rem;
|
|
158
|
+
color: var(--tb-muted);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.tb-review-form {
|
|
162
|
+
display: flex;
|
|
163
|
+
gap: 1rem;
|
|
164
|
+
align-items: flex-end;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
pre {
|
|
168
|
+
background: #0d1117;
|
|
169
|
+
color: #e6edf3;
|
|
170
|
+
padding: 1rem;
|
|
171
|
+
border-radius: 0.5rem;
|
|
172
|
+
overflow-x: auto;
|
|
173
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracebook
|
|
4
|
+
class ExportsController < ApplicationController
|
|
5
|
+
def create
|
|
6
|
+
blob = ExportJob.perform_now(format: params.fetch(:format, :csv), filters: export_filters)
|
|
7
|
+
redirect_to export_path(blob.signed_id), notice: "Export ready"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def show
|
|
11
|
+
blob = ActiveStorage::Blob.find_signed(params[:id])
|
|
12
|
+
send_data blob.download, filename: blob.filename.to_s, type: blob.content_type
|
|
13
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
14
|
+
head :not_found
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def export_filters
|
|
20
|
+
params.fetch(:filters, {}).permit(:provider, :model, :project, :status, :review_state, :from, :to)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
TraceBook = Tracebook unless defined?(TraceBook)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracebook
|
|
4
|
+
class InteractionsController < ApplicationController
|
|
5
|
+
before_action :set_interaction, only: [ :show, :review ]
|
|
6
|
+
helper InteractionsHelper
|
|
7
|
+
|
|
8
|
+
PER_PAGE = 100
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@filters = filter_params
|
|
12
|
+
scope = Interaction.filtered(@filters)
|
|
13
|
+
@kpis = kpis_for(scope)
|
|
14
|
+
@interactions = scope.order(created_at: :desc).limit(PER_PAGE)
|
|
15
|
+
@providers = Interaction.distinct.order(:provider).pluck(:provider)
|
|
16
|
+
@models = Interaction.distinct.order(:model).pluck(:model)
|
|
17
|
+
@projects = Interaction.distinct.order(:project).pluck(:project).compact
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def show
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def review
|
|
24
|
+
state = params.require(:review_state).to_s
|
|
25
|
+
unless Interaction.review_states.key?(state)
|
|
26
|
+
redirect_to interaction_path(@interaction), alert: "Invalid review state: #{state}"
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if @interaction.update(review_state: state)
|
|
31
|
+
redirect_to interaction_path(@interaction), notice: "Review updated"
|
|
32
|
+
else
|
|
33
|
+
render :show, status: :unprocessable_entity
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def bulk_review
|
|
38
|
+
ids = Array(params[:interaction_ids])
|
|
39
|
+
state = params.require(:review_state).to_s
|
|
40
|
+
unless Interaction.review_states.key?(state)
|
|
41
|
+
redirect_to interactions_path, alert: "Invalid review state: #{state}"
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Interaction.where(id: ids).update_all(review_state: Interaction.review_states.fetch(state))
|
|
46
|
+
redirect_to interactions_path, notice: "Updated #{ids.size} interactions"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def set_interaction
|
|
52
|
+
@interaction = Interaction.find(params[:id])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def filter_params
|
|
56
|
+
params.fetch(:filters, {}).permit(:provider, :model, :project, :status, :review_state, :tag, :from, :to)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def kpis_for(scope)
|
|
60
|
+
{
|
|
61
|
+
total: scope.count,
|
|
62
|
+
success: scope.status_success.count,
|
|
63
|
+
cost_cents: scope.sum(:cost_total_cents),
|
|
64
|
+
input_tokens: scope.sum(:input_tokens),
|
|
65
|
+
output_tokens: scope.sum(:output_tokens)
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
TraceBook = Tracebook unless defined?(TraceBook)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Tracebook
|
|
6
|
+
module InteractionsHelper
|
|
7
|
+
def payload_for(interaction, type)
|
|
8
|
+
inline = interaction.public_send("#{type}_payload")
|
|
9
|
+
return inline unless inline.nil? || (inline.respond_to?(:empty?) && inline.empty?)
|
|
10
|
+
|
|
11
|
+
blob = interaction.public_send("#{type}_payload_blob")
|
|
12
|
+
return nil unless blob
|
|
13
|
+
|
|
14
|
+
raw = blob.download
|
|
15
|
+
JSON.parse(raw)
|
|
16
|
+
rescue JSON::ParserError
|
|
17
|
+
raw
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def formatted_payload(payload, fallback_text = nil)
|
|
21
|
+
case payload
|
|
22
|
+
when Hash, Array
|
|
23
|
+
JSON.pretty_generate(payload)
|
|
24
|
+
when String
|
|
25
|
+
payload
|
|
26
|
+
when nil
|
|
27
|
+
fallback_text.to_s
|
|
28
|
+
else
|
|
29
|
+
JSON.pretty_generate(payload.as_json)
|
|
30
|
+
end
|
|
31
|
+
rescue JSON::GeneratorError, TypeError
|
|
32
|
+
fallback_text ? fallback_text.to_s : payload.to_s
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
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)
|
|
@@ -0,0 +1,162 @@
|
|
|
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)
|