tracebook 0.1.1 → 1.0.1
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 +56 -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 +8 -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
|
@@ -7,11 +7,7 @@ module Tracebook
|
|
|
7
7
|
class InstallGenerator < Rails::Generators::Base
|
|
8
8
|
source_root File.expand_path("templates", __dir__)
|
|
9
9
|
|
|
10
|
-
desc "Install
|
|
11
|
-
|
|
12
|
-
def copy_migrations
|
|
13
|
-
rake "tracebook:install:migrations"
|
|
14
|
-
end
|
|
10
|
+
desc "Install Tracebook: create initializer"
|
|
15
11
|
|
|
16
12
|
def create_initializer
|
|
17
13
|
template "initializer.rb.tt", "config/initializers/tracebook.rb"
|
|
@@ -19,16 +15,17 @@ module Tracebook
|
|
|
19
15
|
|
|
20
16
|
def show_next_steps
|
|
21
17
|
say ""
|
|
22
|
-
say "
|
|
18
|
+
say "Tracebook installed!", :green
|
|
23
19
|
say ""
|
|
24
20
|
say "Next steps:"
|
|
25
21
|
say " 1. Run migrations: bin/rails db:migrate"
|
|
26
22
|
say " 2. Mount the engine in config/routes.rb:"
|
|
27
23
|
say ""
|
|
28
|
-
say " mount
|
|
24
|
+
say " mount Tracebook::Engine => \"/tracebook\""
|
|
29
25
|
say ""
|
|
30
|
-
say " 3.
|
|
31
|
-
say " 4.
|
|
26
|
+
say " 3. Seed default pricing: bin/rails tracebook:seed_pricing"
|
|
27
|
+
say " 4. Call Tracebook.calculate_cost! after LLM responses"
|
|
28
|
+
say " (see README for integration examples)"
|
|
32
29
|
say ""
|
|
33
30
|
end
|
|
34
31
|
end
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
#
|
|
3
|
+
Tracebook.configure do |config|
|
|
4
|
+
# Class names for your RubyLLM models (must use acts_as_chat / acts_as_message)
|
|
5
|
+
# config.chat_class = "Chat"
|
|
6
|
+
# config.message_class = "Message"
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
# Currency for cost calculations
|
|
9
|
+
# config.default_currency = "USD"
|
|
7
10
|
|
|
8
|
-
#
|
|
9
|
-
# config.
|
|
11
|
+
# How to display the actor (user) in the dashboard
|
|
12
|
+
# config.actor_display = ->(actor) { actor.try(:name) || actor.try(:email) }
|
|
13
|
+
|
|
14
|
+
# Items per page in the dashboard
|
|
15
|
+
# config.per_page = 25
|
|
10
16
|
end
|
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :tracebook do
|
|
4
|
+
desc "Seed default pricing rules for common LLM providers (Gemini, OpenAI, Anthropic, Ollama)"
|
|
5
|
+
task seed_pricing: :environment do
|
|
6
|
+
result = Tracebook::Seeds::PricingRules.seed!
|
|
7
|
+
|
|
8
|
+
puts "TraceBook pricing rules seeded!"
|
|
9
|
+
puts " Created: #{result[:created]}"
|
|
10
|
+
puts " Skipped (already exist): #{result[:skipped]}"
|
|
11
|
+
|
|
12
|
+
puts "\nRun again after gem updates to add new model pricing." if result[:created] > 0
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -1,97 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "active_support/notifications"
|
|
4
|
-
require "active_support/core_ext/hash/indifferent_access"
|
|
5
|
-
|
|
6
3
|
module Tracebook
|
|
7
4
|
module Adapters
|
|
8
|
-
# Adapter for capturing LLM interactions via ActiveSupport::Notifications.
|
|
9
|
-
#
|
|
10
|
-
# This adapter subscribes to instrumentation events (default: "ruby_llm.request")
|
|
11
|
-
# and automatically records interactions in TraceBook.
|
|
12
|
-
#
|
|
13
|
-
# @example Basic setup
|
|
14
|
-
# # config/initializers/tracebook_adapters.rb
|
|
15
|
-
# TraceBook::Adapters::RubyLLM.enable!
|
|
16
|
-
#
|
|
17
|
-
# @example Custom event name
|
|
18
|
-
# TraceBook::Adapters::RubyLLM.enable!(instrumentation: "my_llm.complete")
|
|
19
|
-
#
|
|
20
|
-
# @example Emitting events from your LLM client
|
|
21
|
-
# ActiveSupport::Notifications.instrument("ruby_llm.request", {
|
|
22
|
-
# provider: "openai",
|
|
23
|
-
# request: { model: "gpt-4o", messages: messages },
|
|
24
|
-
# response: response,
|
|
25
|
-
# meta: {
|
|
26
|
-
# project: "support",
|
|
27
|
-
# user: current_user,
|
|
28
|
-
# session_id: session.id,
|
|
29
|
-
# latency_ms: 150,
|
|
30
|
-
# status: :success,
|
|
31
|
-
# tags: ["production", "triage"]
|
|
32
|
-
# }
|
|
33
|
-
# })
|
|
34
|
-
#
|
|
35
|
-
# @see Mappers
|
|
36
5
|
module RubyLLM
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
DEFAULT_EVENT = "ruby_llm.request".freeze
|
|
41
|
-
|
|
42
|
-
# Enables the adapter to start capturing events.
|
|
43
|
-
#
|
|
44
|
-
# Subscribes to the specified instrumentation event and routes payloads
|
|
45
|
-
# through {Mappers} to {TraceBook.record!}.
|
|
46
|
-
#
|
|
47
|
-
# @param instrumentation [String] Event name to subscribe to
|
|
48
|
-
# @return [void]
|
|
49
|
-
#
|
|
50
|
-
# @example
|
|
51
|
-
# TraceBook::Adapters::RubyLLM.enable!
|
|
52
|
-
# TraceBook::Adapters::RubyLLM.enable!(instrumentation: "custom.llm")
|
|
53
|
-
def enable!(instrumentation: DEFAULT_EVENT)
|
|
54
|
-
return if subscribers.key?(instrumentation)
|
|
55
|
-
|
|
56
|
-
subscribers[instrumentation] = ActiveSupport::Notifications.subscribe(instrumentation) do |*args|
|
|
57
|
-
event = ActiveSupport::Notifications::Event.new(*args)
|
|
58
|
-
handle_payload(event.payload.with_indifferent_access)
|
|
6
|
+
def self.enable!
|
|
7
|
+
unless defined?(::RubyLLM)
|
|
8
|
+
raise LoadError, "RubyLLM is not loaded. Add `gem 'ruby_llm'` to your Gemfile."
|
|
59
9
|
end
|
|
60
|
-
end
|
|
61
10
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
# @param instrumentation [String] Event name to unsubscribe from
|
|
65
|
-
# @return [void]
|
|
66
|
-
#
|
|
67
|
-
# @example
|
|
68
|
-
# TraceBook::Adapters::RubyLLM.disable!
|
|
69
|
-
def disable!(instrumentation: DEFAULT_EVENT)
|
|
70
|
-
token = subscribers.delete(instrumentation)
|
|
71
|
-
ActiveSupport::Notifications.unsubscribe(token) if token
|
|
11
|
+
message_class = Tracebook.config.message_class.constantize
|
|
12
|
+
message_class.include(CostTracking)
|
|
72
13
|
end
|
|
73
14
|
|
|
74
|
-
|
|
15
|
+
module CostTracking
|
|
16
|
+
extend ActiveSupport::Concern
|
|
75
17
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
provider,
|
|
80
|
-
raw_request: payload[:request],
|
|
81
|
-
raw_response: payload[:response],
|
|
82
|
-
meta: payload[:meta] || {}
|
|
83
|
-
)
|
|
18
|
+
included do
|
|
19
|
+
after_create_commit :tracebook_calculate_cost, if: :assistant?
|
|
20
|
+
end
|
|
84
21
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def tracebook_calculate_cost
|
|
25
|
+
model_id = chat.model_id
|
|
26
|
+
provider = ::RubyLLM::Models.find(model_id).provider
|
|
89
27
|
|
|
90
|
-
|
|
91
|
-
|
|
28
|
+
Tracebook.calculate_cost!(self, provider: provider, model: model_id)
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
Rails.logger.error("[Tracebook] Cost calculation failed for message #{id}: #{e.class} - #{e.message}")
|
|
31
|
+
end
|
|
92
32
|
end
|
|
93
33
|
end
|
|
94
34
|
end
|
|
95
35
|
end
|
|
96
|
-
|
|
97
|
-
TraceBook = Tracebook unless defined?(TraceBook)
|
data/lib/tracebook/adapters.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
module Tracebook
|
|
4
|
+
module Adapters
|
|
5
|
+
autoload :RubyLLM, "tracebook/adapters/ruby_llm"
|
|
6
|
+
end
|
|
7
|
+
end
|
data/lib/tracebook/config.rb
CHANGED
|
@@ -1,135 +1,114 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Tracebook
|
|
4
|
-
# Configuration object for TraceBook.
|
|
5
|
-
#
|
|
6
|
-
# Contains all configurable options for the TraceBook engine. Configuration
|
|
7
|
-
# is frozen after the {Tracebook.configure} block executes to prevent
|
|
8
|
-
# runtime modifications.
|
|
9
|
-
#
|
|
10
|
-
# @example Basic configuration
|
|
11
|
-
# TraceBook.configure do |config|
|
|
12
|
-
# config.project_name = "Support Console"
|
|
13
|
-
# config.persist_async = Rails.env.production?
|
|
14
|
-
# config.default_currency = "USD"
|
|
15
|
-
# end
|
|
16
|
-
#
|
|
17
|
-
# @example With custom redactors
|
|
18
|
-
# TraceBook.configure do |config|
|
|
19
|
-
# config.custom_redactors += [
|
|
20
|
-
# ->(payload) { payload.gsub(/api_key=\w+/, "api_key=[REDACTED]") }
|
|
21
|
-
# ]
|
|
22
|
-
# end
|
|
23
|
-
#
|
|
24
|
-
# @see Tracebook.configure
|
|
25
4
|
class Config
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
#
|
|
33
|
-
# When true, {Tracebook.record!} enqueues {PersistInteractionJob}.
|
|
34
|
-
# When false, interactions are persisted inline.
|
|
35
|
-
attr_accessor :persist_async
|
|
36
|
-
|
|
37
|
-
# @!attribute [rw] inline_payload_bytes
|
|
38
|
-
# @return [Integer] Maximum payload size before spilling to ActiveStorage (default: 64KB)
|
|
39
|
-
# Payloads larger than this threshold are stored as ActiveStorage blobs
|
|
40
|
-
# instead of inline JSONB columns.
|
|
41
|
-
attr_accessor :inline_payload_bytes
|
|
42
|
-
|
|
43
|
-
# @!attribute [rw] default_currency
|
|
44
|
-
# @return [String] Currency code for cost calculations (default: "USD")
|
|
45
|
-
# Used in {PricingRule} cost tracking
|
|
5
|
+
# @return [String] class name of the host app's Chat model (default: "Chat")
|
|
6
|
+
attr_accessor :chat_class
|
|
7
|
+
|
|
8
|
+
# @return [String] class name of the host app's Message model (default: "Message")
|
|
9
|
+
attr_accessor :message_class
|
|
10
|
+
|
|
11
|
+
# @return [String] currency code for cost calculations (default: "USD")
|
|
46
12
|
attr_accessor :default_currency
|
|
47
13
|
|
|
48
|
-
#
|
|
49
|
-
# @return [Array<Symbol>] Available export formats (default: [:csv, :ndjson])
|
|
50
|
-
# Supported formats for {ExportJob}
|
|
51
|
-
attr_accessor :export_formats
|
|
52
|
-
|
|
53
|
-
# @!attribute [rw] redactors
|
|
54
|
-
# @return [Array<Redactor>] Built-in PII redactors
|
|
55
|
-
# Default redactors for email, phone, credit card numbers.
|
|
56
|
-
# See {Redactors::Email}, {Redactors::Phone}, {Redactors::CardPAN}
|
|
57
|
-
attr_accessor :redactors
|
|
58
|
-
|
|
59
|
-
# @!attribute [rw] custom_redactors
|
|
60
|
-
# @return [Array<Proc>] Custom redaction lambdas
|
|
61
|
-
# Additional user-defined redactors that receive the payload string
|
|
62
|
-
# and return a redacted version.
|
|
63
|
-
# @example
|
|
64
|
-
# config.custom_redactors += [
|
|
65
|
-
# ->(payload) { payload.gsub(/secret=\w+/, "secret=[REDACTED]") }
|
|
66
|
-
# ]
|
|
67
|
-
attr_accessor :custom_redactors
|
|
68
|
-
|
|
69
|
-
# @!attribute [rw] auto_subscribe_ruby_llm
|
|
70
|
-
# @return [Boolean] Auto-enable RubyLLM adapter on boot (default: false)
|
|
71
|
-
attr_accessor :auto_subscribe_ruby_llm
|
|
72
|
-
|
|
73
|
-
# @!attribute [rw] auto_subscribe_active_agent
|
|
74
|
-
# @return [Boolean] Auto-enable ActiveAgent adapter on boot (default: false)
|
|
75
|
-
attr_accessor :auto_subscribe_active_agent
|
|
76
|
-
|
|
77
|
-
# @!attribute [rw] per_page
|
|
78
|
-
# @return [Integer] Number of interactions per page in dashboard (default: 100)
|
|
14
|
+
# @return [Integer] items per page in dashboard (default: 25)
|
|
79
15
|
attr_accessor :per_page
|
|
80
16
|
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
|
|
17
|
+
# @return [Proc, nil] lambda to format actor display name
|
|
18
|
+
attr_accessor :actor_display
|
|
19
|
+
|
|
20
|
+
# @return [Array<Proc>] custom redaction callables
|
|
21
|
+
attr_reader :custom_redactors
|
|
22
|
+
|
|
84
23
|
def initialize
|
|
85
|
-
@
|
|
86
|
-
@
|
|
87
|
-
@inline_payload_bytes = 64 * 1024
|
|
24
|
+
@chat_class = "Chat"
|
|
25
|
+
@message_class = "Message"
|
|
88
26
|
@default_currency = "USD"
|
|
89
|
-
@
|
|
90
|
-
@
|
|
27
|
+
@per_page = 25
|
|
28
|
+
@actor_display = nil
|
|
29
|
+
@redaction_patterns = []
|
|
91
30
|
@custom_redactors = []
|
|
92
|
-
@auto_subscribe_ruby_llm = false
|
|
93
|
-
@auto_subscribe_active_agent = false
|
|
94
|
-
@per_page = 100
|
|
95
31
|
end
|
|
96
32
|
|
|
97
|
-
#
|
|
33
|
+
# Enable named redaction patterns.
|
|
98
34
|
#
|
|
99
|
-
# @
|
|
100
|
-
|
|
101
|
-
|
|
35
|
+
# @example
|
|
36
|
+
# config.redact :email, :phone, :ssn, :credit_card
|
|
37
|
+
def redact(*names)
|
|
38
|
+
names.each do |name|
|
|
39
|
+
if Redaction::GROUPS.key?(name)
|
|
40
|
+
redact_group(name)
|
|
41
|
+
elsif Redaction::PATTERNS.key?(name)
|
|
42
|
+
@redaction_patterns << name unless @redaction_patterns.include?(name)
|
|
43
|
+
else
|
|
44
|
+
raise ArgumentError, "Unknown redaction pattern: #{name}. Available: #{(Redaction::PATTERNS.keys + Redaction::GROUPS.keys).join(", ")}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Enable a named group of patterns.
|
|
50
|
+
#
|
|
51
|
+
# @example
|
|
52
|
+
# config.redact_group :api_keys
|
|
53
|
+
def redact_group(group_name)
|
|
54
|
+
patterns = Redaction::GROUPS.fetch(group_name) do
|
|
55
|
+
raise ArgumentError, "Unknown redaction group: #{group_name}. Available: #{Redaction::GROUPS.keys.join(", ")}"
|
|
56
|
+
end
|
|
57
|
+
patterns.each { |name| redact(name) }
|
|
102
58
|
end
|
|
103
59
|
|
|
104
|
-
#
|
|
60
|
+
# Add a custom regex pattern for redaction.
|
|
105
61
|
#
|
|
106
|
-
#
|
|
62
|
+
# @example
|
|
63
|
+
# config.redact_pattern(/policy[:\s]*\d{10}/i, "[POLICY_NUMBER]", name: "policy_number")
|
|
64
|
+
def redact_pattern(regex, replacement, name: nil)
|
|
65
|
+
pattern = Redaction::Pattern.new(
|
|
66
|
+
name: name || "custom_#{@redaction_patterns.size}",
|
|
67
|
+
regex: regex,
|
|
68
|
+
replacement: replacement
|
|
69
|
+
)
|
|
70
|
+
@redaction_patterns << pattern
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Build the redaction pipeline from configured patterns and custom redactors.
|
|
74
|
+
# Memoized after first call (safe because config is frozen after finalize!).
|
|
107
75
|
#
|
|
108
|
-
# @return [
|
|
76
|
+
# @return [Redaction::Pipeline]
|
|
77
|
+
def redaction_pipeline
|
|
78
|
+
@redaction_pipeline || build_redaction_pipeline
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def finalized?
|
|
82
|
+
@finalized == true
|
|
83
|
+
end
|
|
84
|
+
|
|
109
85
|
def finalize!
|
|
110
86
|
return if finalized?
|
|
111
87
|
|
|
88
|
+
@redaction_patterns.freeze
|
|
89
|
+
@custom_redactors.freeze
|
|
90
|
+
@redaction_pipeline = redaction_pipeline
|
|
112
91
|
@finalized = true
|
|
113
|
-
freeze_collections!
|
|
114
92
|
freeze
|
|
115
93
|
end
|
|
116
94
|
|
|
117
|
-
|
|
95
|
+
# @return [Class] the resolved chat model class
|
|
96
|
+
def chat_model
|
|
97
|
+
@chat_class.constantize
|
|
98
|
+
end
|
|
118
99
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
Tracebook::Redactors::Phone.new,
|
|
123
|
-
Tracebook::Redactors::CardPAN.new
|
|
124
|
-
]
|
|
100
|
+
# @return [Class] the resolved message model class
|
|
101
|
+
def message_model
|
|
102
|
+
@message_class.constantize
|
|
125
103
|
end
|
|
126
104
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def build_redaction_pipeline
|
|
108
|
+
patterns = @redaction_patterns.map do |p|
|
|
109
|
+
p.is_a?(Symbol) ? Redaction::PATTERNS.fetch(p) : p
|
|
110
|
+
end
|
|
111
|
+
Redaction::Pipeline.new(patterns: patterns, custom_redactors: @custom_redactors)
|
|
131
112
|
end
|
|
132
113
|
end
|
|
133
114
|
end
|
|
134
|
-
|
|
135
|
-
TraceBook = Tracebook unless defined?(TraceBook)
|
data/lib/tracebook/engine.rb
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
module Tracebook
|
|
2
2
|
class Engine < ::Rails::Engine
|
|
3
3
|
isolate_namespace Tracebook
|
|
4
|
+
|
|
5
|
+
initializer :append_migrations do |app|
|
|
6
|
+
unless app.root.to_s.match?(root.to_s)
|
|
7
|
+
config.paths["db/migrate"].expanded.each do |expanded_path|
|
|
8
|
+
app.config.paths["db/migrate"] << expanded_path unless app.config.paths["db/migrate"].include?(expanded_path)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
4
12
|
end
|
|
5
13
|
end
|
data/lib/tracebook/errors.rb
CHANGED
|
@@ -24,16 +24,21 @@ module Tracebook
|
|
|
24
24
|
def matching_rule(provider, model, occurred_at)
|
|
25
25
|
Tracebook::PricingRule.where(provider: provider).select do |rule|
|
|
26
26
|
rule.matches_model?(model) && rule.active_on?(occurred_at.to_date)
|
|
27
|
-
end.
|
|
27
|
+
end.max_by { |rule| [ glob_specificity(rule.model_glob), rule.effective_from ] }
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
def
|
|
31
|
-
|
|
30
|
+
def glob_specificity(glob)
|
|
31
|
+
glob.delete("*?").length
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Calculates cost in cents.
|
|
35
|
+
# cents_per_unit is cents per 1M tokens (matching the pricing rule storage).
|
|
36
|
+
# Example: $2.50/1M = 250 cents/1M. 1000 tokens => 1000 * 250 / 1_000_000 = 0.25 cents.
|
|
37
|
+
def cost_for(cents_per_million, tokens)
|
|
38
|
+
return 0 if cents_per_million.to_d <= 0 || tokens.to_i <= 0
|
|
32
39
|
|
|
33
|
-
(tokens.to_i
|
|
40
|
+
(tokens.to_i * cents_per_million.to_d / 1_000_000).round(4)
|
|
34
41
|
end
|
|
35
42
|
end
|
|
36
43
|
end
|
|
37
44
|
end
|
|
38
|
-
|
|
39
|
-
TraceBook = Tracebook unless defined?(TraceBook)
|
data/lib/tracebook/pricing.rb
CHANGED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracebook
|
|
4
|
+
module Redaction
|
|
5
|
+
# A single redaction pattern with regex, replacement, and optional validator.
|
|
6
|
+
Pattern = Data.define(:name, :regex, :replacement, :validator) do
|
|
7
|
+
def initialize(name:, regex:, replacement:, validator: nil)
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def redact(text)
|
|
12
|
+
return text unless text.is_a?(String)
|
|
13
|
+
|
|
14
|
+
text.gsub(regex) do |match|
|
|
15
|
+
if validator && !validator.call(match)
|
|
16
|
+
match
|
|
17
|
+
else
|
|
18
|
+
replacement
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Luhn algorithm for credit card validation
|
|
25
|
+
LUHN = ->(number) {
|
|
26
|
+
digits = number.gsub(/\D/, "")
|
|
27
|
+
return false if digits.length < 13 || digits.length > 19
|
|
28
|
+
|
|
29
|
+
sum = 0
|
|
30
|
+
digits.reverse.each_char.with_index do |d, i|
|
|
31
|
+
n = d.to_i
|
|
32
|
+
n *= 2 if i.odd?
|
|
33
|
+
n -= 9 if n > 9
|
|
34
|
+
sum += n
|
|
35
|
+
end
|
|
36
|
+
(sum % 10).zero?
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
PATTERNS = {
|
|
40
|
+
email: Pattern.new(
|
|
41
|
+
name: :email,
|
|
42
|
+
regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/,
|
|
43
|
+
replacement: "[EMAIL]"
|
|
44
|
+
),
|
|
45
|
+
phone: Pattern.new(
|
|
46
|
+
name: :phone,
|
|
47
|
+
regex: /(?<!\d)\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b/,
|
|
48
|
+
replacement: "[PHONE]"
|
|
49
|
+
),
|
|
50
|
+
ssn: Pattern.new(
|
|
51
|
+
name: :ssn,
|
|
52
|
+
regex: /\b\d{3}-\d{2}-\d{4}\b/,
|
|
53
|
+
replacement: "[SSN]",
|
|
54
|
+
validator: ->(match) {
|
|
55
|
+
area = match[0..2].to_i
|
|
56
|
+
area > 0 && area != 666 && area < 900
|
|
57
|
+
}
|
|
58
|
+
),
|
|
59
|
+
credit_card: Pattern.new(
|
|
60
|
+
name: :credit_card,
|
|
61
|
+
regex: /\b(?:\d[ -]*?){13,19}\b/,
|
|
62
|
+
replacement: "[CREDIT_CARD]",
|
|
63
|
+
validator: LUHN
|
|
64
|
+
),
|
|
65
|
+
openai_key: Pattern.new(
|
|
66
|
+
name: :openai_key,
|
|
67
|
+
regex: /\bsk-[A-Za-z0-9_-]{20,}\b/,
|
|
68
|
+
replacement: "[KEY]"
|
|
69
|
+
),
|
|
70
|
+
anthropic_key: Pattern.new(
|
|
71
|
+
name: :anthropic_key,
|
|
72
|
+
regex: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/,
|
|
73
|
+
replacement: "[KEY]"
|
|
74
|
+
),
|
|
75
|
+
aws_key: Pattern.new(
|
|
76
|
+
name: :aws_key,
|
|
77
|
+
regex: /\bAKIA[0-9A-Z]{16}\b/,
|
|
78
|
+
replacement: "[KEY]"
|
|
79
|
+
),
|
|
80
|
+
stripe_key: Pattern.new(
|
|
81
|
+
name: :stripe_key,
|
|
82
|
+
regex: /\b[sr]k_(test|live)_[A-Za-z0-9]{20,}\b/,
|
|
83
|
+
replacement: "[KEY]"
|
|
84
|
+
),
|
|
85
|
+
github_token: Pattern.new(
|
|
86
|
+
name: :github_token,
|
|
87
|
+
regex: /\bgh[pousr]_[A-Za-z0-9_]{36,}\b/,
|
|
88
|
+
replacement: "[KEY]"
|
|
89
|
+
),
|
|
90
|
+
bearer_token: Pattern.new(
|
|
91
|
+
name: :bearer_token,
|
|
92
|
+
regex: /Bearer\s+[A-Za-z0-9\-._~+\/]+=*/i,
|
|
93
|
+
replacement: "Bearer [REDACTED]"
|
|
94
|
+
),
|
|
95
|
+
jwt: Pattern.new(
|
|
96
|
+
name: :jwt,
|
|
97
|
+
regex: /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/,
|
|
98
|
+
replacement: "[JWT]"
|
|
99
|
+
),
|
|
100
|
+
ipv4: Pattern.new(
|
|
101
|
+
name: :ipv4,
|
|
102
|
+
regex: /\b(?:\d{1,3}\.){3}\d{1,3}\b/,
|
|
103
|
+
replacement: "[IP_ADDRESS]",
|
|
104
|
+
validator: ->(match) {
|
|
105
|
+
match.split(".").all? { |octet| octet.to_i.between?(0, 255) }
|
|
106
|
+
}
|
|
107
|
+
),
|
|
108
|
+
private_key: Pattern.new(
|
|
109
|
+
name: :private_key,
|
|
110
|
+
regex: /-----BEGIN [A-Z ]+ PRIVATE KEY-----.*?-----END [A-Z ]+ PRIVATE KEY-----/m,
|
|
111
|
+
replacement: "[PRIVATE_KEY]"
|
|
112
|
+
)
|
|
113
|
+
}.freeze
|
|
114
|
+
|
|
115
|
+
GROUPS = {
|
|
116
|
+
pii: %i[email phone ssn],
|
|
117
|
+
financial: %i[credit_card],
|
|
118
|
+
api_keys: %i[openai_key anthropic_key aws_key stripe_key github_token],
|
|
119
|
+
auth: %i[bearer_token jwt],
|
|
120
|
+
network: %i[ipv4],
|
|
121
|
+
crypto: %i[private_key]
|
|
122
|
+
}.freeze
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracebook
|
|
4
|
+
module Redaction
|
|
5
|
+
class Pipeline
|
|
6
|
+
attr_reader :patterns, :custom_redactors
|
|
7
|
+
|
|
8
|
+
def initialize(patterns: [], custom_redactors: [])
|
|
9
|
+
@patterns = patterns
|
|
10
|
+
@custom_redactors = custom_redactors
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Redact a string through all patterns and custom redactors.
|
|
14
|
+
#
|
|
15
|
+
# @param text [String] the text to redact
|
|
16
|
+
# @return [String] redacted text
|
|
17
|
+
def call(text)
|
|
18
|
+
return text unless text.is_a?(String)
|
|
19
|
+
return text if patterns.empty? && custom_redactors.empty?
|
|
20
|
+
|
|
21
|
+
result = text
|
|
22
|
+
patterns.each { |pattern| result = pattern.redact(result) }
|
|
23
|
+
custom_redactors.each { |redactor| result = redactor.call(result) }
|
|
24
|
+
result
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def active?
|
|
28
|
+
patterns.any? || custom_redactors.any?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|