Class: RailsErrorDashboard::Services::BreadcrumbCollector

Inherits:
Object
  • Object
show all
Defined in:
lib/rails_error_dashboard/services/breadcrumb_collector.rb

Overview

Pure service: Collect breadcrumbs (request activity trail) for error context

Uses a thread-local ring buffer to capture events during a request lifecycle. Thread.current isolation means no mutex/lock is needed.

SAFETY RULES (HOST_APP_SAFETY.md):

  • Every public method wrapped in rescue => e; nil

  • Never raise, never block, never allocate large objects

  • Messages truncated to 500 chars, metadata values to 200 chars

  • Ring buffer has fixed max size (no unbounded growth)

Defined Under Namespace

Classes: RingBuffer

Constant Summary collapse

THREAD_KEY =
:red_breadcrumbs
MAX_MESSAGE_LENGTH =
500
MAX_METADATA_VALUE_LENGTH =
200
MAX_METADATA_KEYS =
10
LLM_CATEGORIES =

Categories whose metadata is emitted by LlmCallEvent#to_breadcrumb_metadata.

%w[llm llm_tool].freeze
LLM_STRUCTURED_METADATA_KEYS =

Keys on llm / llm_tool crumbs whose values are structured (numeric counts, identifiers, durations, costs) and must not be redacted by the ActiveSupport::ParameterFilter substring matcher. Without this whitelist, the default ‘:token` pattern matches `input_tokens` / `output_tokens` and corrupts the LlmSummary rollup, sidebar card, and markdown export tokens column. Sensitive-looking fields like tool_arguments / tool_result / error_message are intentionally NOT in this list — they go through the filter as usual because they can carry user-provided content.

%w[
  provider model status
  input_tokens output_tokens duration_ms
  tool_name error_class cost_usd
].freeze

Class Method Summary collapse

Class Method Details

.add(category, message, duration_ms: nil, metadata: nil) ⇒ Object

Add a breadcrumb to the current buffer

Parameters:

  • category (String)

    Event category (sql, controller, cache, job, mailer, custom)

  • message (String)

    Human-readable description

  • duration_ms (Float, nil) (defaults to: nil)

    Duration in milliseconds

  • metadata (Hash, nil) (defaults to: nil)

    Optional key-value pairs



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/rails_error_dashboard/services/breadcrumb_collector.rb', line 93

def self.add(category, message, duration_ms: nil, metadata: nil)
  buffer = Thread.current[THREAD_KEY]
  return unless buffer

  # Check category filter
  allowed = RailsErrorDashboard.configuration.breadcrumb_categories
  if allowed
    cat_sym = category.to_s.to_sym
    return unless allowed.include?(cat_sym)
  end

  # Build breadcrumb entry with compact keys
  entry = {
    t: (Time.now.to_f * 1000).to_i,
    c: category.to_s,
    m: truncate_message(message)
  }

  entry[:d] = duration_ms if duration_ms
  entry[:meta] = () if .is_a?(Hash)

  buffer.add(entry)
rescue => e
  RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.add failed: #{e.message}")
  nil
end

.clear_bufferObject

Clear the ring buffer (end of request — MUST be called in ensure block)



81
82
83
84
85
86
# File 'lib/rails_error_dashboard/services/breadcrumb_collector.rb', line 81

def self.clear_buffer
  Thread.current[THREAD_KEY] = nil
rescue => e
  RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.clear_buffer failed: #{e.message}")
  nil
end

.current_breadcrumbsArray<Hash>

Non-destructive read of current breadcrumbs (does NOT clear the buffer) Used by DiagnosticDumpGenerator for on-demand snapshots.

Returns:

  • (Array<Hash>)

    Array of breadcrumb hashes (empty if none)



137
138
139
140
141
142
143
144
# File 'lib/rails_error_dashboard/services/breadcrumb_collector.rb', line 137

def self.current_breadcrumbs
  buffer = Thread.current[THREAD_KEY]
  return [] unless buffer
  buffer.to_a
rescue => e
  RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.current_breadcrumbs failed: #{e.message}")
  []
end

.current_bufferRingBuffer?

Get the current buffer (for inspection)

Returns:



148
149
150
151
152
153
# File 'lib/rails_error_dashboard/services/breadcrumb_collector.rb', line 148

def self.current_buffer
  Thread.current[THREAD_KEY]
rescue => e
  RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.current_buffer failed: #{e.message}")
  nil
end

.filter_sensitive(breadcrumbs) ⇒ Array<Hash>

Filter sensitive data from breadcrumbs before storage Reuses existing SensitiveDataFilter — no new filter logic

Parameters:

  • breadcrumbs (Array<Hash>)

    Raw breadcrumbs

Returns:

  • (Array<Hash>)

    Filtered breadcrumbs



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/rails_error_dashboard/services/breadcrumb_collector.rb', line 159

def self.filter_sensitive(breadcrumbs)
  return [] unless breadcrumbs.is_a?(Array)
  return breadcrumbs unless RailsErrorDashboard.configuration.filter_sensitive_data

  filter = SensitiveDataFilter.parameter_filter
  return breadcrumbs unless filter

  breadcrumbs.map do |crumb|
    filtered = crumb.dup

    # Filter message (SQL queries, key=value patterns)
    if filtered[:m]
      filtered[:m] = SensitiveDataFilter.send(:filter_message, filter, filtered[:m])
    end

    # Filter metadata values
    if filtered[:meta].is_a?(Hash)
      filtered[:meta] = (filter, filtered[:c], filtered[:meta])
    end

    filtered
  end
rescue => e
  RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.filter_sensitive failed: #{e.message}")
  breadcrumbs.is_a?(Array) ? breadcrumbs : []
end

.harvestArray<Hash>

Harvest breadcrumbs from the current buffer and clear it

Returns:

  • (Array<Hash>)

    Array of breadcrumb hashes (empty if none)



122
123
124
125
126
127
128
129
130
131
132
# File 'lib/rails_error_dashboard/services/breadcrumb_collector.rb', line 122

def self.harvest
  buffer = Thread.current[THREAD_KEY]
  return [] unless buffer

  result = buffer.to_a
  buffer.clear
  result
rescue => e
  RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.harvest failed: #{e.message}")
  []
end

.init_bufferObject

Initialize a new ring buffer for the current thread (start of request)



72
73
74
75
76
77
78
# File 'lib/rails_error_dashboard/services/breadcrumb_collector.rb', line 72

def self.init_buffer
  size = RailsErrorDashboard.configuration.breadcrumb_buffer_size || 40
  Thread.current[THREAD_KEY] = RingBuffer.new(size)
rescue => e
  RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.init_buffer failed: #{e.message}")
  nil
end