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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -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 +8 -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
@@ -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 TraceBook: copy migrations and create initializer"
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 "TraceBook installed!", :green
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 TraceBook::Engine => \"/tracebook\""
24
+ say " mount Tracebook::Engine => \"/tracebook\""
29
25
  say ""
30
- say " 3. Configure authorization in config/initializers/tracebook.rb"
31
- say " 4. Set up ActiveRecord encryption (see README)"
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
- TraceBook.configure do |config|
4
- # config.project_name = "My App"
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
- config.persist_async = Rails.env.production?
8
+ # Currency for cost calculations
9
+ # config.default_currency = "USD"
7
10
 
8
- # config.auto_subscribe_ruby_llm = true
9
- # config.auto_subscribe_active_agent = true
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
- # desc "Explaining what the task does"
2
- # task :tracebook do
3
- # # Task goes here
4
- # end
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
- extend self
38
-
39
- # Default ActiveSupport::Notifications event name
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
- # Disables the adapter and unsubscribes from events.
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
- private
15
+ module CostTracking
16
+ extend ActiveSupport::Concern
75
17
 
76
- def handle_payload(payload)
77
- provider = payload[:provider].to_s.presence || "ruby_llm"
78
- normalized = Mappers.normalize(
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
- TraceBook.record!(**normalized.to_h)
86
- rescue KeyError => error
87
- Rails.logger.error("TraceBook RubyLLM adapter error: #{error.message}") if defined?(Rails)
88
- end
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
- def subscribers
91
- @subscribers ||= {}
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)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "adapters/ruby_llm"
4
- require_relative "adapters/active_agent"
5
-
6
- TraceBook = Tracebook unless defined?(TraceBook)
3
+ module Tracebook
4
+ module Adapters
5
+ autoload :RubyLLM, "tracebook/adapters/ruby_llm"
6
+ end
7
+ end
@@ -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
- # @!attribute [rw] project_name
27
- # @return [String, nil] Project identifier for this application (optional)
28
- # Used to filter interactions by project in the dashboard
29
- attr_accessor :project_name
30
-
31
- # @!attribute [rw] persist_async
32
- # @return [Boolean] Whether to persist interactions asynchronously (default: true)
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
- # @!attribute [rw] export_formats
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
- # Creates a new configuration with default values.
82
- #
83
- # @return [Config]
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
- @project_name = nil
86
- @persist_async = true
87
- @inline_payload_bytes = 64 * 1024
24
+ @chat_class = "Chat"
25
+ @message_class = "Message"
88
26
  @default_currency = "USD"
89
- @export_formats = [ :csv, :ndjson ]
90
- @redactors = default_redactors
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
- # Returns true if configuration has been finalized.
33
+ # Enable named redaction patterns.
98
34
  #
99
- # @return [Boolean]
100
- def finalized?
101
- @finalized == true
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
- # Freezes the configuration to prevent further changes.
60
+ # Add a custom regex pattern for redaction.
105
61
  #
106
- # Called automatically by {Tracebook.configure} after the block executes.
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 [void]
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
- private
95
+ # @return [Class] the resolved chat model class
96
+ def chat_model
97
+ @chat_class.constantize
98
+ end
118
99
 
119
- def default_redactors
120
- [
121
- Tracebook::Redactors::Email.new,
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
- def freeze_collections!
128
- @redactors = @redactors.map { |redactor| redactor }.freeze
129
- @custom_redactors = @custom_redactors.map { |callable| callable }.freeze
130
- @export_formats = @export_formats.map(&:to_sym).freeze
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)
@@ -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
@@ -5,5 +5,3 @@ module Tracebook
5
5
 
6
6
  class ConfigurationError < Error; end
7
7
  end
8
-
9
- TraceBook = Tracebook unless defined?(TraceBook)
@@ -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.min_by(&:effective_from)
27
+ end.max_by { |rule| [ glob_specificity(rule.model_glob), rule.effective_from ] }
28
28
  end
29
29
 
30
- def cost_for(cents_per_unit, tokens)
31
- return 0 if cents_per_unit.to_i <= 0 || tokens.to_i <= 0
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 / 1000.0 * cents_per_unit.to_i).round
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)
@@ -1,5 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "pricing/calculator"
4
-
5
- TraceBook = Tracebook unless defined?(TraceBook)
@@ -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