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
-
.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
RAW 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.
- .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).
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.}") end end nil end |
.adapters ⇒ Object
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.
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() 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() 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 |