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,
lib/foam/otel/llm/ruby_openai_shim.rb,
lib/foam/otel/llm/http_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
* `ruby-openai` (the long-standing COMMUNITY OpenAI client —
               OpenAI::Client#chat(parameters:) / #embeddings; shares
               the OpenAI namespace with the official gem, so presence
               is checked by the parameters:-keyword SHAPE, never the
               bare method name) — chat + embeddings
* `http`        (HTTP.rb) — hand-rolled-provider-client pattern: POSTs
               to api.anthropic.com/v1/messages from SDK-less apps;
               shape-checked per call so every other HTTP.rb request
               in the process keeps its plain transport span

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

* Content is CAPTURED, then redacted BEFORE serialization (coverage
contract C4, security-fixes-design — the F-RB2 fix): the credential
floor, the value-pattern secret layer and customer-listed redact
keys run over the PARSED messages/system/output structures, and the
masked structures land JSON-serialized on gen_ai.input.messages /
gen_ai.system_instructions / gen_ai.output.messages. Unmatched,
unshaped content is captured verbatim (raw capture stands).
* 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 gen_ai span: the SDK's own HTTP
send runs inside the OTel suppression context (Common::Utilities
.untraced — the same mechanism the exporters use, GOTCHAS G4).
Instrumentations that honor it (net_http, excon) emit no transport
span at all; those that DON'T (contrib -http — GOTCHAS G13, the
UNGUARDABLE list) emit a transport span that NESTS beneath the
gen_ai span — the contract's sanctioned rendering (SPEC §10: a
different layer, like rails-inside-rack). Either way exactly one
gen_ai span. Feature-detected; Utilities absent → nesting.
* 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, HttpAnthropicShim, OpenAIShim, RubyLLMShim, RubyOpenAIShim

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).



89
90
91
92
93
94
95
96
97
98
# File 'lib/foam/otel/llm.rb', line 89

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



83
84
85
# File 'lib/foam/otel/llm.rb', line 83

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

.content_string(value) ⇒ Object

Content string: strings verbatim, other shapes JSON-serialized (respecting to_json overrides — the official SDKs' typed models serialize themselves). nil/empty → nil (attribute omitted).

F-RB2 fix (security-fixes-design C4, CWE-312): structured content is redacted BEFORE serialization. Serializing first buried listed keys (tool-call arguments, structured content blocks, RAG context) inside a String scalar the exporter-boundary descend() can never see into — so the full pass (credential floor + value-pattern layer + the customer's redact_keys/redact_pii_keys) runs over the PARSED structure here, and the MASKED structure is what serializes. The serialized string still gets the value-pattern scan again at the export boundary (defense in depth, C4.2).

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 for unmatched, unshaped content).



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/foam/otel/llm.rb', line 182

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

  raw = value.is_a?(String) ? value : masked_content_json(value)
  scrubbed = Redaction.scrub_utf8(raw)
  scrubbed.is_a?(String) && !scrubbed.empty? ? scrubbed : nil
rescue StandardError, SystemStackError
  begin
    # Degraded shape (e.g. to_json raising on invalid bytes): still
    # scrubbed AND still masked — mask_body runs the same engine over
    # the stringified fallback, fail-closed.
    fallback = Redaction.mask_body(Redaction.scrub_utf8(value.to_s), Foam::Otel.active_config)
    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)


101
102
103
104
105
# File 'lib/foam/otel/llm.rb', line 101

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

.masked_content_json(value) ⇒ Object

C4: serialize (respecting to_json overrides), parse back to plain walkable structures, run the full redaction pass, re-serialize the masked structure. The JSON round-trip is what lets the engine see inside the SDKs' typed model objects without foam knowing their shapes. Raises propagate to content_string's guarded fallback.



205
206
207
208
# File 'lib/foam/otel/llm.rb', line 205

def masked_content_json(value)
  parsed = JSON.parse(value.to_json)
  Redaction.mask_body(parsed, Foam::Otel.active_config).to_json
end

.minimal_request(provider, operation) ⇒ Object



118
119
120
121
122
123
124
125
# File 'lib/foam/otel/llm.rb', line 118

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, error_detector: 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.



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/foam/otel/llm.rb', line 133

def observe(provider:, request:, operation: "chat", response_extractor: nil, error_detector: 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
    # Non-raising transports (HTTP.rb: a 4xx/5xx is a RETURN VALUE,
    # not an exception) surface their error through the detector —
    # the observed status becomes the span status (rule 25).
    detected = error_detector&.call(result)
    if detected
      span.status = OpenTelemetry::Trace::Status.error(detected.to_s)
    end
    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.



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/foam/otel/llm.rb', line 214

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.



241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/foam/otel/llm.rb', line 241

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.



111
112
113
114
115
116
# File 'lib/foam/otel/llm.rb', line 111

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