Module: Foam::Otel::LLM

Defined in:
lib/foam/otel/llm.rb,
lib/foam/otel/llm/gemini_shim.rb,
lib/foam/otel/llm/openai_shim.rb,
lib/foam/otel/llm/ruby_llm_shim.rb,
lib/foam/otel/llm/anthropic_shim.rb

Overview

The LLM surface (provider-parity ruling 2026-07-26: OpenAI + Anthropic + Google Gemini in every server language). The Ruby contrib registry has NO official instrumentation for any LLM SDK, so foam ships thin presence-checked gap-filler shims — the Ruby twins of python's _openai_responses.py and js/otel's anthropic gap-filler — over the SDKs' public call sites:

* `openai`     (official OpenAI Ruby SDK)  — chat.completions.create +
                                           responses.create
* `anthropic`  (official Anthropic Ruby SDK) — messages.create
* `gemini-ai`  (the de-facto Gemini Ruby SDK; Google ships no official
              Ruby SDK) — generate_content / stream_generate_content
* `ruby_llm`   (multi-provider client) — Provider#complete, one seam
              covering its OpenAI/Anthropic/Gemini/... providers

Contract invariants (identical to the python/js gap-fillers):

* Content is CAPTURED, RAW: prompts/system instructions/responses land
verbatim (JSON-serialized) on gen_ai.input.messages /
gen_ai.system_instructions / gen_ai.output.messages. Masking happens
ONLY for customer-listed redact keys, at foam's redaction stage.
* SUPER SAFE: presence-checked (absent SDK = inert), version-guarded,
fail-to-dark; the wrapper NEVER throws into app code or fails the
customer's model call — the SDK call's own error re-raises
IDENTICALLY, telemetry failures degrade to an unspanned call.
* ONE logical LLM call = ONE foam client span: the SDK's own HTTP send
runs inside the OTel suppression context the official http
instrumentations honor (Common::Utilities.untraced — the same
mechanism the exporters use, GOTCHAS G4), so the transport is never
double-spanned. Feature-detected; absent → the call runs in the
gen_ai span's context and the transport span parents under it.
* Wire vocabulary (rule 26): current GenAI semconv exactly as the
fleet's other packages emit it — span name "{operation} {model}",
gen_ai.operation.name / gen_ai.provider.name / request+response
model / response id / finish_reasons / usage tokens / the three
content attributes; error.type (+ http.response.status_code only
when OBSERVED) on the error path. Never a fabricated value.

Activation (init.rb) is gated on foam owning the TRACES slot (rule 18 B) and each emit re-checks, so patches installed once per process go dark whenever foam is not exporting traces.

Defined Under Namespace

Modules: AnthropicShim, GeminiShim, OpenAIShim, RubyLLMShim

Constant Summary collapse

GEN_AI_OPERATION_NAME =
"gen_ai.operation.name"
GEN_AI_PROVIDER_NAME =
"gen_ai.provider.name"
GEN_AI_REQUEST_MODEL =
"gen_ai.request.model"
GEN_AI_REQUEST_MAX_TOKENS =
"gen_ai.request.max_tokens"
GEN_AI_RESPONSE_MODEL =
"gen_ai.response.model"
GEN_AI_RESPONSE_ID =
"gen_ai.response.id"
GEN_AI_RESPONSE_FINISH_REASONS =
"gen_ai.response.finish_reasons"
GEN_AI_USAGE_INPUT_TOKENS =
"gen_ai.usage.input_tokens"
GEN_AI_USAGE_OUTPUT_TOKENS =
"gen_ai.usage.output_tokens"
GEN_AI_INPUT_MESSAGES =
"gen_ai.input.messages"
GEN_AI_OUTPUT_MESSAGES =
"gen_ai.output.messages"
GEN_AI_SYSTEM_INSTRUCTIONS =
"gen_ai.system_instructions"
ERROR_TYPE =
"error.type"
HTTP_RESPONSE_STATUS_CODE =
"http.response.status_code"
TRACER_NAME =
"foam-llm"

Class Method Summary collapse

Class Method Details

.activate!Object

Presence-checked, fault-isolated activation — one broken adapter never blocks the others, and nothing here can abort init (rule 9).



74
75
76
77
78
79
80
81
82
83
# File 'lib/foam/otel/llm.rb', line 74

def activate!
  adapters.each do |adapter|
    begin
      adapter.install! if adapter.present?
    rescue StandardError => e
      Diagnostics.warn("LLM shim #{adapter.name} skipped: #{e.class}: #{e.message}")
    end
  end
  nil
end

.adaptersObject



68
69
70
# File 'lib/foam/otel/llm.rb', line 68

def adapters
  [OpenAIShim, AnthropicShim, GeminiShim, RubyLLMShim]
end

.content_string(value) ⇒ Object

RAW content string: strings verbatim, other shapes JSON-serialized (respecting to_json overrides — the official SDKs' typed models serialize themselves). nil/empty → nil (attribute omitted). scrub_utf8 is encoding hygiene, not masking: prompts are customer data, and ONE invalid-UTF-8 content attribute makes the upstream OTLP encoder drop the WHOLE span batch (rule 15) — invalid bytes become U+FFFD, content is otherwise untouched (raw capture stands).



148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/foam/otel/llm.rb', line 148

def content_string(value)
  return nil if value.nil?

  raw = value.is_a?(String) ? value : value.to_json
  scrubbed = Redaction.scrub_utf8(raw)
  scrubbed.is_a?(String) && !scrubbed.empty? ? scrubbed : nil
rescue StandardError, SystemStackError
  begin
    fallback = Redaction.scrub_utf8(value.to_s)
    fallback.is_a?(String) && !fallback.empty? ? fallback : nil
  rescue StandardError, SystemStackError
    nil
  end
end

.emit?Boolean

The emit-time gate: foam initialized, enabled, and owning traces.

Returns:

  • (Boolean)


86
87
88
89
90
# File 'lib/foam/otel/llm.rb', line 86

def emit?
  Foam::Otel.send(:foam_exports_signal?, :traces) && !Foam::Otel.send(:helpers_disabled?)
rescue StandardError
  false
end

.minimal_request(provider, operation) ⇒ Object



103
104
105
106
107
108
109
110
# File 'lib/foam/otel/llm.rb', line 103

def minimal_request(provider, operation)
  {
    GEN_AI_OPERATION_NAME => operation.to_s,
    GEN_AI_PROVIDER_NAME => provider.to_s,
  }
rescue StandardError, SystemStackError
  {}
end

.observe(provider:, request:, operation: "chat", response_extractor: nil, &block) ⇒ Object

Wraps ONE model call. request carries the pre-extracted request attributes; response_extractor maps the SDK's return value to response attributes. The block is the ORIGINAL SDK call: its return value passes through untouched, its error re-raises identically. Every telemetry step is individually guarded — a shim failure can only ever mean a missing span, never a broken model call.



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/foam/otel/llm.rb', line 118

def observe(provider:, request:, operation: "chat", response_extractor: nil, &block)
  return yield unless emit?

  span = start_span(provider, operation, request)
  return yield if span.nil?

  begin
    result = run_in_span(span, &block)
  rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised identically below
    record_failure(span, e)
    finish_span(span)
    raise
  end
  begin
    response = response_extractor&.call(result)
    apply_attributes(span, response) if response
  rescue StandardError, SystemStackError
    nil
  end
  finish_span(span)
  result
end

.request_attributes(provider:, operation:, model: nil, max_tokens: nil, input_messages: nil, system_instructions: nil) ⇒ Object

Builds the request-side attribute hash for observe. NEVER raises — the adapters call this OUTSIDE observe's guards, so a poisoned params object must degrade to the bare operation/provider pair, not break the model call.



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/foam/otel/llm.rb', line 167

def request_attributes(provider:, operation:, model: nil, max_tokens: nil,
                       input_messages: nil, system_instructions: nil)
  attrs = {
    GEN_AI_OPERATION_NAME => operation,
    GEN_AI_PROVIDER_NAME => provider,
  }
  begin
    if model
      # scrubbed like the content: the model string is customer input
      # and ALSO becomes the span name — invalid bytes would kill the
      # whole span batch at the OTLP encoder.
      model_string = Redaction.scrub_utf8(model.to_s)
      attrs[GEN_AI_REQUEST_MODEL] = model_string if model_string.is_a?(String) && !model_string.empty?
    end
    attrs[GEN_AI_REQUEST_MAX_TOKENS] = max_tokens if max_tokens.is_a?(Integer)
    input = content_string(input_messages)
    attrs[GEN_AI_INPUT_MESSAGES] = input if input
    system = content_string(system_instructions)
    attrs[GEN_AI_SYSTEM_INSTRUCTIONS] = system if system
  rescue StandardError, SystemStackError
    nil
  end
  attrs
end

.response_attributes(model: nil, id: nil, finish_reasons: nil, input_tokens: nil, output_tokens: nil, output_messages: nil) ⇒ Object

Builds the response-side attribute hash adapters return from their response extractors.



194
195
196
197
198
199
200
201
202
203
204
205
206
# File 'lib/foam/otel/llm.rb', line 194

def response_attributes(model: nil, id: nil, finish_reasons: nil,
                        input_tokens: nil, output_tokens: nil, output_messages: nil)
  attrs = {}
  attrs[GEN_AI_RESPONSE_MODEL] = model.to_s if model && !model.to_s.empty?
  attrs[GEN_AI_RESPONSE_ID] = id.to_s if id && !id.to_s.empty?
  reasons = Array(finish_reasons).map(&:to_s).reject(&:empty?)
  attrs[GEN_AI_RESPONSE_FINISH_REASONS] = reasons unless reasons.empty?
  attrs[GEN_AI_USAGE_INPUT_TOKENS] = input_tokens if input_tokens.is_a?(Integer)
  attrs[GEN_AI_USAGE_OUTPUT_TOKENS] = output_tokens if output_tokens.is_a?(Integer)
  output = content_string(output_messages)
  attrs[GEN_AI_OUTPUT_MESSAGES] = output if output
  attrs
end

.safe_request(provider:, operation: "chat") ⇒ Object

Builds a request-attribute hash INSIDE a guard: a shim extractor bug degrades to the bare operation/provider pair (activity survives, content is lost) — never a raise into the customer's call path. The patches route ALL request extraction through this.



96
97
98
99
100
101
# File 'lib/foam/otel/llm.rb', line 96

def safe_request(provider:, operation: "chat")
  attrs = yield
  attrs.is_a?(Hash) ? attrs : minimal_request(provider, operation)
rescue StandardError, SystemStackError
  minimal_request(provider, operation)
end