Class: RailsErrorDashboard::Integrations::LlmSpanProcessor

Inherits:
Object
  • Object
show all
Defined in:
lib/rails_error_dashboard/integrations/llm_span_processor.rb

Overview

OpenTelemetry SpanProcessor that maps GenAI semantic-convention spans into LLM breadcrumbs. Registered with ‘OpenTelemetry.tracer_provider` when the host app already runs OTel (ruby_llm, thoughtbot/instrumentation, etc. all emit GenAI spans automatically).

IMPORTANT — does NOT subclass ::OpenTelemetry::SDK::Trace::SpanProcessor. That would NameError at file-load time on hosts without the SDK. Ruby’s OTel SDK accepts any duck-typed processor — name + arity is the contract.

Reads attribute keys per the GenAI semconv (current + deprecated aliases). Spec: opentelemetry.io/docs/specs/semconv/gen-ai/

HOST APP SAFETY:

  • on_finish wraps the entire body in rescue StandardError => nil

  • No work happens unless enable_llm_observability AND enable_breadcrumbs

  • Non-GenAI spans return immediately (cheapest possible path)

  • Never raises, never blocks the tracer pipeline

Constant Summary collapse

PROVIDER_KEYS =

Attribute keys — current GenAI semconv, with deprecated aliases.

[ "gen_ai.provider.name", "gen_ai.system" ].freeze
MODEL_KEYS =
[ "gen_ai.response.model", "gen_ai.request.model" ].freeze
INPUT_TOKEN_KEYS =
[ "gen_ai.usage.input_tokens", "gen_ai.usage.prompt_tokens" ].freeze
OUTPUT_TOKEN_KEYS =
[ "gen_ai.usage.output_tokens", "gen_ai.usage.completion_tokens" ].freeze
TOOL_NAME_KEY =
"gen_ai.tool.name"
OPERATION_KEY =
"gen_ai.operation.name"
ERROR_TYPE_KEY =
"error.type"

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.register!Boolean

Idempotently register a single shared LlmSpanProcessor instance with the host’s OpenTelemetry tracer provider. Called from Engine ‘after_initialize` when `enable_llm_observability` is on.

Returns false (and does nothing) when:

  • OTel SDK isn’t loaded (‘Integrations::OTel.available?` is false)

  • ‘enable_llm_observability` is off

  • The active tracer provider is the default ‘ProxyTracerProvider` (SDK loaded but `OpenTelemetry::SDK.configure` never called) —detected by absence of `add_span_processor`

  • Already registered in this process (Spring reload safety)

  • ‘add_span_processor` raises (host app safety — never crash boot)

Returns:

  • (Boolean)

    true if a processor was newly registered, false otherwise



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/rails_error_dashboard/integrations/llm_span_processor.rb', line 38

def register!
  return false if @registered
  return false unless RailsErrorDashboard.configuration.enable_llm_observability
  return false unless OTel.available?

  provider = ::OpenTelemetry.tracer_provider
  return false unless provider.respond_to?(:add_span_processor)

  provider.add_span_processor(new)
  @registered = true
  true
rescue StandardError => e
  RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] LlmSpanProcessor.register! failed: #{e.message}")
  false
end

.registered?Boolean

Returns:

  • (Boolean)


62
63
64
# File 'lib/rails_error_dashboard/integrations/llm_span_processor.rb', line 62

def registered?
  @registered == true
end

.reset!Object

Test hook — clear the registered flag so re-registration is possible in a fresh spec example. Does NOT remove the processor from the tracer provider (OTel SDK offers no symmetric ‘remove_span_processor`).



57
58
59
# File 'lib/rails_error_dashboard/integrations/llm_span_processor.rb', line 57

def reset!
  @registered = false
end

Instance Method Details

#force_flush(timeout: nil) ⇒ Object

OTel SDK Export::SUCCESS == 0. Hardcoded so this file loads without OTel.



106
107
108
# File 'lib/rails_error_dashboard/integrations/llm_span_processor.rb', line 106

def force_flush(timeout: nil)
  0
end

#on_finish(span) ⇒ Object

Required SpanProcessor interface. Must never raise.



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/rails_error_dashboard/integrations/llm_span_processor.rb', line 83

def on_finish(span)
  return unless RailsErrorDashboard.configuration.enable_llm_observability
  return unless RailsErrorDashboard.configuration.enable_breadcrumbs

  attrs = safe_attributes(span)
  return if attrs.empty?
  return unless gen_ai_span?(attrs)

  event   = build_event(span, attrs)
  category = event.tool_call? ? "llm_tool" : "llm"

  Services::BreadcrumbCollector.add(
    category,
    event.to_breadcrumb_message,
    duration_ms: event.duration_ms,
    metadata: event.
  )
rescue StandardError => e
  RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] LlmSpanProcessor.on_finish failed: #{e.message}")
  nil
end

#on_start(_span, _parent_context) ⇒ Object

Required SpanProcessor interface — no-op. We only act when the span is fully populated (attributes/timestamps/status), which is on_finish.



78
79
80
# File 'lib/rails_error_dashboard/integrations/llm_span_processor.rb', line 78

def on_start(_span, _parent_context)
  nil
end

#shutdown(timeout: nil) ⇒ Object



110
111
112
# File 'lib/rails_error_dashboard/integrations/llm_span_processor.rb', line 110

def shutdown(timeout: nil)
  0
end