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



77
78
79
80
81
82
83
84
85
86
# File 'lib/foam/otel/llm.rb', line 77

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



71
72
73
# File 'lib/foam/otel/llm.rb', line 71

def adapters
  [OpenAIShim, AnthropicShim, GeminiShim, RubyLLMShim]
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).



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/foam/otel/llm.rb', line 163

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)


89
90
91
92
93
# File 'lib/foam/otel/llm.rb', line 89

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.



186
187
188
189
# File 'lib/foam/otel/llm.rb', line 186

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



106
107
108
109
110
111
112
113
# File 'lib/foam/otel/llm.rb', line 106

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.



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

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.



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/foam/otel/llm.rb', line 195

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.



222
223
224
225
226
227
228
229
230
231
232
233
234
# File 'lib/foam/otel/llm.rb', line 222

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.



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

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