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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -24
  3. data/README.md +197 -713
  4. data/app/assets/javascripts/tracebook/application.js +92 -35
  5. data/app/assets/stylesheets/tracebook/application.css +1882 -55
  6. data/app/controllers/tracebook/application_controller.rb +25 -0
  7. data/app/controllers/tracebook/chats_controller.rb +229 -0
  8. data/app/controllers/tracebook/comments_controller.rb +25 -0
  9. data/app/helpers/tracebook/chats_helper.rb +29 -0
  10. data/app/models/tracebook/chat_review.rb +19 -0
  11. data/app/models/tracebook/comment.rb +14 -0
  12. data/app/models/tracebook/message_cost.rb +12 -0
  13. data/app/models/tracebook/pricing_rule.rb +6 -8
  14. data/app/views/tracebook/chats/index.html.erb +77 -0
  15. data/app/views/tracebook/chats/show.html.erb +94 -0
  16. data/config/routes.rb +6 -6
  17. data/db/migrate/20260325000100_create_tracebook_message_costs.rb +19 -0
  18. data/db/migrate/20260325000200_create_tracebook_chat_reviews.rb +19 -0
  19. data/db/migrate/{20241112000300_create_tracebook_pricing_rules.rb → 20260325000300_create_tracebook_pricing_rules.rb} +3 -3
  20. data/db/migrate/20260325000500_create_tracebook_comments.rb +15 -0
  21. data/lib/generators/tracebook/install/install_generator.rb +6 -9
  22. data/lib/generators/tracebook/install/templates/initializer.rb.tt +11 -5
  23. data/lib/tasks/tracebook_tasks.rake +14 -4
  24. data/lib/tracebook/adapters/ruby_llm.rb +19 -81
  25. data/lib/tracebook/adapters.rb +5 -4
  26. data/lib/tracebook/config.rb +83 -104
  27. data/lib/tracebook/engine.rb +6 -0
  28. data/lib/tracebook/errors.rb +0 -2
  29. data/lib/tracebook/pricing/calculator.rb +11 -6
  30. data/lib/tracebook/pricing.rb +0 -2
  31. data/lib/tracebook/redaction/pattern.rb +124 -0
  32. data/lib/tracebook/redaction/pipeline.rb +32 -0
  33. data/lib/tracebook/seeds/pricing_rules.rb +62 -0
  34. data/lib/tracebook/version.rb +1 -1
  35. data/lib/tracebook.rb +46 -152
  36. metadata +23 -51
  37. data/app/controllers/tracebook/exports_controller.rb +0 -25
  38. data/app/controllers/tracebook/interactions_controller.rb +0 -71
  39. data/app/helpers/tracebook/interactions_helper.rb +0 -35
  40. data/app/jobs/tracebook/daily_rollups_job.rb +0 -100
  41. data/app/jobs/tracebook/export_job.rb +0 -162
  42. data/app/jobs/tracebook/persist_interaction_job.rb +0 -160
  43. data/app/mailers/tracebook/application_mailer.rb +0 -6
  44. data/app/models/tracebook/interaction.rb +0 -103
  45. data/app/models/tracebook/redaction_rule.rb +0 -81
  46. data/app/models/tracebook/rollup_daily.rb +0 -73
  47. data/app/views/tracebook/interactions/index.html.erb +0 -108
  48. data/app/views/tracebook/interactions/show.html.erb +0 -44
  49. data/db/migrate/20241112000100_create_tracebook_interactions.rb +0 -55
  50. data/db/migrate/20241112000200_create_tracebook_rollups_dailies.rb +0 -24
  51. data/db/migrate/20241112000400_create_tracebook_redaction_rules.rb +0 -19
  52. data/lib/tracebook/adapters/active_agent.rb +0 -82
  53. data/lib/tracebook/mappers/anthropic.rb +0 -59
  54. data/lib/tracebook/mappers/base.rb +0 -38
  55. data/lib/tracebook/mappers/ollama.rb +0 -49
  56. data/lib/tracebook/mappers/openai.rb +0 -75
  57. data/lib/tracebook/mappers.rb +0 -283
  58. data/lib/tracebook/normalized_interaction.rb +0 -86
  59. data/lib/tracebook/redaction_pipeline.rb +0 -88
  60. data/lib/tracebook/redactors/base.rb +0 -29
  61. data/lib/tracebook/redactors/card_pan.rb +0 -15
  62. data/lib/tracebook/redactors/email.rb +0 -15
  63. data/lib/tracebook/redactors/phone.rb +0 -15
  64. data/lib/tracebook/redactors.rb +0 -8
  65. 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,6 +0,0 @@
1
- module Tracebook
2
- class ApplicationMailer < ActionMailer::Base
3
- default from: "from@example.com"
4
- layout "mailer"
5
- end
6
- end
@@ -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)