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)



158
159
160
161
162
163
164
165
# File 'lib/rails_error_dashboard/services/breadcrumb_collector.rb', line 158

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:



169
170
171
172
173
174
# File 'lib/rails_error_dashboard/services/breadcrumb_collector.rb', line 169

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



180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/rails_error_dashboard/services/breadcrumb_collector.rb', line 180

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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/rails_error_dashboard/services/breadcrumb_collector.rb', line 122

def self.harvest
  # OTel: emit a child span around the harvest so operators see the
  # buffer-drain step in the capture trace. Cheap to compute (single
  # Array#size + JSON byte estimate) and contained to LogError invocations
  # via the parent rails_error_dashboard.capture_error span.
  RailsErrorDashboard::Integrations::Tracer.in_span(
    "breadcrumb_collection",
    kind: :breadcrumbs
  ) do |span|
    buffer = Thread.current[THREAD_KEY]
    if buffer.nil?
      span&.set_attribute("breadcrumb_count", 0)
      next []
    end

    result = buffer.to_a
    buffer.clear

    # Only pay for attribute computation when a real span is recording.
    # NoopSpan is the singleton returned when OTel is off — skip the work
    # entirely so the harvest path stays free in the common case.
    if span && !span.equal?(RailsErrorDashboard::Integrations::Tracer::NOOP_SPAN)
      span.set_attribute("breadcrumb_count", result.size)
      span.set_attribute("bytes_serialized_estimate", estimate_byte_size(result))
    end

    result
  end
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