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
-
.activate! ⇒ Object
Presence-checked, fault-isolated activation — one broken adapter never blocks the others, and nothing here can abort init (rule 9).
- .adapters ⇒ Object
-
.content_string(value) ⇒ Object
Content string: strings verbatim, other shapes JSON-serialized (respecting to_json overrides — the official SDKs' typed models serialize themselves).
-
.emit? ⇒ Boolean
The emit-time gate: foam initialized, enabled, and owning traces.
-
.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.
- .minimal_request(provider, operation) ⇒ Object
-
.observe(provider:, request:, operation: "chat", response_extractor: nil, &block) ⇒ Object
Wraps ONE model call.
-
.request_attributes(provider:, operation:, model: nil, max_tokens: nil, input_messages: nil, system_instructions: nil) ⇒ Object
Builds the request-side attribute hash for observe.
-
.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.
-
.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.
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.}") end end nil end |
.adapters ⇒ Object
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.
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() 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() 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 |