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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +10 -0
  3. data/CHANGELOG.md +43 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +881 -0
  6. data/Rakefile +21 -0
  7. data/app/assets/images/tracebook/.keep +0 -0
  8. data/app/assets/javascripts/tracebook/application.js +88 -0
  9. data/app/assets/stylesheets/tracebook/application.css +173 -0
  10. data/app/controllers/concerns/.keep +0 -0
  11. data/app/controllers/tracebook/application_controller.rb +4 -0
  12. data/app/controllers/tracebook/exports_controller.rb +25 -0
  13. data/app/controllers/tracebook/interactions_controller.rb +71 -0
  14. data/app/helpers/tracebook/application_helper.rb +4 -0
  15. data/app/helpers/tracebook/interactions_helper.rb +35 -0
  16. data/app/jobs/tracebook/application_job.rb +5 -0
  17. data/app/jobs/tracebook/daily_rollups_job.rb +100 -0
  18. data/app/jobs/tracebook/export_job.rb +162 -0
  19. data/app/jobs/tracebook/persist_interaction_job.rb +160 -0
  20. data/app/mailers/tracebook/application_mailer.rb +6 -0
  21. data/app/models/concerns/.keep +0 -0
  22. data/app/models/tracebook/application_record.rb +5 -0
  23. data/app/models/tracebook/interaction.rb +100 -0
  24. data/app/models/tracebook/pricing_rule.rb +84 -0
  25. data/app/models/tracebook/redaction_rule.rb +81 -0
  26. data/app/models/tracebook/rollup_daily.rb +73 -0
  27. data/app/views/layouts/tracebook/application.html.erb +18 -0
  28. data/app/views/tracebook/interactions/index.html.erb +105 -0
  29. data/app/views/tracebook/interactions/show.html.erb +44 -0
  30. data/config/routes.rb +8 -0
  31. data/db/migrate/20241112000100_create_tracebook_interactions.rb +55 -0
  32. data/db/migrate/20241112000200_create_tracebook_rollups_dailies.rb +24 -0
  33. data/db/migrate/20241112000300_create_tracebook_pricing_rules.rb +21 -0
  34. data/db/migrate/20241112000400_create_tracebook_redaction_rules.rb +19 -0
  35. data/lib/tasks/tracebook_tasks.rake +4 -0
  36. data/lib/tasks/yard.rake +29 -0
  37. data/lib/tracebook/adapters/active_agent.rb +82 -0
  38. data/lib/tracebook/adapters/ruby_llm.rb +97 -0
  39. data/lib/tracebook/adapters.rb +6 -0
  40. data/lib/tracebook/config.rb +130 -0
  41. data/lib/tracebook/engine.rb +5 -0
  42. data/lib/tracebook/errors.rb +9 -0
  43. data/lib/tracebook/mappers/anthropic.rb +59 -0
  44. data/lib/tracebook/mappers/base.rb +38 -0
  45. data/lib/tracebook/mappers/ollama.rb +49 -0
  46. data/lib/tracebook/mappers/openai.rb +75 -0
  47. data/lib/tracebook/mappers.rb +283 -0
  48. data/lib/tracebook/normalized_interaction.rb +86 -0
  49. data/lib/tracebook/pricing/calculator.rb +39 -0
  50. data/lib/tracebook/pricing.rb +5 -0
  51. data/lib/tracebook/redaction_pipeline.rb +88 -0
  52. data/lib/tracebook/redactors/base.rb +29 -0
  53. data/lib/tracebook/redactors/card_pan.rb +15 -0
  54. data/lib/tracebook/redactors/email.rb +15 -0
  55. data/lib/tracebook/redactors/phone.rb +15 -0
  56. data/lib/tracebook/redactors.rb +8 -0
  57. data/lib/tracebook/result.rb +53 -0
  58. data/lib/tracebook/version.rb +3 -0
  59. data/lib/tracebook.rb +201 -0
  60. 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,4 @@
1
+ module Tracebook
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -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,4 @@
1
+ module Tracebook
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -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,5 @@
1
+ module Tracebook
2
+ class ApplicationJob < ActiveJob::Base
3
+ queue_as :tracebook
4
+ end
5
+ 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)