foam-otel 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb41e8c47c944ac8f252f47bededb07f8c73e152411f3a7069a8fcab3d1185a2
4
- data.tar.gz: ab93229a7820e4b60439d8c30f903fd3d1be93a8d2dd723b032862813ac4278f
3
+ metadata.gz: 4e10efb9aaacdda1498a19efaa4af8338afbb98d89880cc6f662aa99205aab59
4
+ data.tar.gz: 436060cbcfc7ed33c171ab230a60d4f8b62a882ce46be22bcd81e9c497edbc84
5
5
  SHA512:
6
- metadata.gz: 291f81494a360ee3ce94a09b6b98dafde0574e4078d61ec01bb3e3e3b7803a4f7034bc36ef17a33db79cbb886ad5c0d337c2ffa75762c5a9d04f52e89cdf6669
7
- data.tar.gz: e81c1c075606402562ee74a8dc6752dd4d96f4b73235303a43cd0d20569ca7238a031b3c6c6d6726f5cf3f3c32858c2452cc5cbfac8ffd61f834f549738c6c1b
6
+ metadata.gz: 528f57356698e39e691b08bcc0b6f00bc409ad133afca0ac0bdeeb2d0caffbc052a7a19d7fe63b0285230eb16cd26117ede4bc0824c7c0f31377a4a983ad7458
7
+ data.tar.gz: 9e445e3ce3924ff924afd66e98c35b58fe536ae2d546a0f151d3d870f9359974b3bbfd781871fd39fef67d3d509573cab7c2ce573531067eedf3623943967f32
data/GOTCHAS.md CHANGED
@@ -681,6 +681,22 @@ anthropic 1.59.0, gemini-ai 4.3.0, ruby_llm 1.16.0).
681
681
  beside it (`lib/foam/otel/runtime_metrics.rb` `guarded_callback`).
682
682
  *Test*: `spec/runtime_metrics_spec.rb` ("a poisoned reading is
683
683
  fail-to-dark", the foreign-slot and idempotence cases).
684
+ - **G16 — Sidekiq's queue-hop trace shape is `propagation_style: :link`**:
685
+ the bundled contrib instrumentation's default (installed source:
686
+ opentelemetry-instrumentation-sidekiq-0.29.0 `instrumentation.rb:104`)
687
+ makes a performed job a SEPARATE trace whose first (consumer) span carries
688
+ a span LINK back to the enqueuing producer span — NOT a child inside the
689
+ web request's trace. Foam ships that upstream default untouched (rule 26:
690
+ the contrib pin sets the semconv; `FLOOR_INSTRUMENTATION_CONFIG` overrides
691
+ only the datastore raw-capture posture), so dashboards that follow ONLY
692
+ parent/child edges see the web trace end at the enqueue — follow the link,
693
+ or clamp the posture per deployment with the standard contrib lever
694
+ `OTEL_RUBY_INSTRUMENTATION_SIDEKIQ_CONFIG_OPTS='propagation_style=child'`
695
+ for one continuous web→job trace. Context is propagated across the hop
696
+ either way — a hop that DROPS it is a correctness bug, never accepted.
697
+ *Test*: `spec/floor_sidekiq_wire_spec.rb` (real Redis enqueue→perform:
698
+ producer span joins the request trace; consumer span LINKS the producer
699
+ span on the wire).
684
700
 
685
701
  ---
686
702
 
data/README.md CHANGED
@@ -80,6 +80,14 @@ attributes, URLs/query strings, DB statement text, log bodies, LLM content —
80
80
  is captured RAW.** Pass `redact_keys`/`redact_pii_keys` to mask or erase
81
81
  specific fields (see below).
82
82
 
83
+ > **Sidekiq trace shape** (GOTCHAS G16): the bundled instrumentation keeps
84
+ > its upstream default `propagation_style: :link` — a performed job runs as
85
+ > its OWN trace whose first span LINKS back to the enqueuing span (context is
86
+ > always propagated across the hop; proven against a real Redis in
87
+ > `spec/floor_sidekiq_wire_spec.rb`). Prefer one continuous web→job trace?
88
+ > Set the standard contrib lever:
89
+ > `OTEL_RUBY_INSTRUMENTATION_SIDEKIQ_CONFIG_OPTS='propagation_style=child'`.
90
+
83
91
  ### The LLM surface — OpenAI, Anthropic, Gemini (and ruby_llm)
84
92
 
85
93
  The Ruby contrib registry ships no LLM instrumentation, so foam does: thin,
@@ -87,7 +95,14 @@ presence-checked shims over the SDKs' public call sites, emitting the standard
87
95
  `gen_ai.*` semantic conventions — activity (model, response id, finish
88
96
  reasons, token usage, latency) AND prompt/response **content, RAW**
89
97
  (`gen_ai.input.messages` / `gen_ai.output.messages` /
90
- `gen_ai.system_instructions`). Covered: the official `openai` SDK
98
+ `gen_ai.system_instructions`). Agent tool loops ride in full: tool-call
99
+ turns keep their tool names/arguments and tool-result turns keep their
100
+ `tool_call_id` linkage inside the message content. `gen_ai.operation.name`
101
+ (and the `{operation} {model}` span name) follows the semconv operation for
102
+ each API shape — `chat` for the OpenAI/Anthropic/ruby_llm chat seams,
103
+ `generate_content` for Gemini's generateContent, fleet-identical with the
104
+ js/python Gemini paths (**wire change in 1.2.1**: Gemini spans previously
105
+ said `chat`). Covered: the official `openai` SDK
91
106
  (chat.completions.create + responses.create), the official `anthropic` SDK
92
107
  (messages.create), Gemini via the `gemini-ai` gem (Google ships no official
93
108
  Ruby SDK), and `ruby_llm` (one seam covering all its providers). Absent SDK =
data/RESEARCH.md CHANGED
@@ -151,6 +151,20 @@ the customer lists in `redact_keys`/`redact_pii_keys`. An FDE can still clamp
151
151
  context calls for it — per-customer tightening (rule 18 / Area 4), not the
152
152
  default.
153
153
 
154
+ **Sidekiq queue-hop posture (documented, GOTCHAS G16):** `-sidekiq 0.29.0`
155
+ defaults `propagation_style: :link` (installed source `instrumentation.rb:104`)
156
+ — a performed job is a SEPARATE trace whose consumer span LINKS the enqueuing
157
+ producer span. Foam ships the upstream default untouched
158
+ (`FLOOR_INSTRUMENTATION_CONFIG` overrides only the datastore raw-capture
159
+ posture); the continuous web→job shape is one env lever away
160
+ (`OTEL_RUBY_INSTRUMENTATION_SIDEKIQ_CONFIG_OPTS='propagation_style=child'`).
161
+ Context always survives the hop. Wire-proven per rule 8b against a REAL
162
+ redis-server with the REAL sidekiq gem (enqueue → Redis → embedded-server
163
+ perform; producer/consumer spans + the link asserted from the exporter):
164
+ `spec/floor_sidekiq_wire_spec.rb`. The Faraday floor entry carries the same
165
+ rule-8b emission proof against a local listener:
166
+ `spec/floor_wire_spec.rb`.
167
+
154
168
  **Runtime + GC metrics (hand-written — landed,
155
169
  `lib/foam/otel/runtime_metrics.rb`).** Ruby has no drop-in upstream metrics
156
170
  instrumentation gem (unlike Python's `-system-metrics` or the Node
@@ -165,8 +179,19 @@ construction (rule 26). Tests: `spec/runtime_metrics_spec.rb`.
165
179
 
166
180
  **Excluded (with reason):** `-factory_bot`, `-rspec` (test-only, no production
167
181
  value); `-net_ldap` (niche, PII/credential-adjacent — explicit FDE opt-in);
182
+ `-logger 0.4.0` (SUPERSEDED by foam's own stdlib Logger bridge, §4: both
183
+ patch the SAME `Logger#add` seam, and foam's bridge keeps the lazy-block
184
+ contract, both loop guards, the logs-slot gating, and the opt-in redaction
185
+ path the contrib bridge lacks. Two bridges on one seam would export every
186
+ host log line TWICE — rule 12 never-double-attach — so init's bundle-driven
187
+ sweep pins the contrib gem disabled (`SUPERSEDED_INSTRUMENTATION_CONFIG`,
188
+ `lib/foam/otel/init.rb`; proven by `spec/floor_spec.rb`); an FDE who
189
+ deliberately wants the contrib bridge instead passes its instance via
190
+ `additional_instrumentations`, which bypasses the sweep map);
168
191
  `all`/`base` (not instrumentations — foam deliberately does NOT depend on the
169
- `all` meta-gem, which would drag test gems).
192
+ `all` meta-gem, which would drag test gems). With the 17-gem floor, the 27
193
+ when-present gems, and these 4 exclusions, all 48 registry gems have a
194
+ written disposition (rule 6: no silent omissions).
170
195
 
171
196
  **Known-bad / crash history (rule 6 / R3):** action_pack overrides rack config
172
197
  (contrib #88); active_record 0.2.2 failed on Rails 7 (#38); `-que` NoMethodError
@@ -197,6 +222,14 @@ one prepend covers most framework logging in practice;
197
222
  https://github.com/open-telemetry/opentelemetry-ruby/discussions/1789 is
198
223
  open/unresolved) — a documented gap, narrowed to the non-Logger-backed exotics.
199
224
 
225
+ The official contrib `opentelemetry-instrumentation-logger 0.4.0` bridges the
226
+ same `Logger#add` seam and is deliberately NOT bundled — census disposition
227
+ EXCLUDED/superseded (§3): foam's bridge additionally holds the lazy-block
228
+ contract, the OTel-internal-logger identity guard, the re-entrancy flag, the
229
+ logs-slot gate, and the opt-in redaction path, and shipping/sweeping both
230
+ would double-export every host log line (rule 12). init's sweep pins the
231
+ contrib gem disabled even when an FDE bundles it (`spec/floor_spec.rb`).
232
+
200
233
  ---
201
234
 
202
235
  ## 5. LLM ecosystem (rule 31)
@@ -210,7 +243,16 @@ shims patch the SDKs' public call sites — official `openai`
210
243
  (messages.create), `gemini-ai` (generate_content / stream_generate_content;
211
244
  Google ships no official Ruby SDK), and `ruby_llm` (Provider#complete, one
212
245
  seam covering all its providers) — emitting the same GenAI semconv attribute
213
- set the fleet's js/python gap-fillers emit. **LLM traces are captured in
246
+ set the fleet's js/python gap-fillers emit. `gen_ai.operation.name` matches
247
+ the fleet per API shape: `chat` on the chat seams, `generate_content` on the
248
+ Gemini shim (aligned in 1.2.1 — Ruby previously emitted `chat` for Gemini, a
249
+ cross-language vocabulary split against the js shim and python's official
250
+ google-genai instrumentor). Agent-loop tool activity is captured on the
251
+ ruby_llm seam too: assistant tool-call turns serialize their `tool_calls`
252
+ (id/name/arguments) and tool-result turns their `tool_call_id` into
253
+ `gen_ai.input.messages`/`gen_ai.output.messages`
254
+ (`spec/llm_ruby_llm_spec.rb` drives a real `chat.with_tool` loop on the
255
+ wire); the Anthropic shim carries tool_use blocks verbatim inside content. **LLM traces are captured in
214
256
  full — activity (model, response id, finish reasons, tokens, latency) AND
215
257
  prompt/completion content, raw by default** (2026-07-26 ruling: LLM content
216
258
  is never gated or dropped; the FDE reactive clamp is the only per-customer
@@ -470,6 +470,21 @@ module Foam
470
470
  "OpenTelemetry::Instrumentation::Mongo" => { db_statement: :include }.freeze,
471
471
  }.freeze
472
472
 
473
+ # Contrib gems foam's OWN floor pieces supersede (rule-6 census:
474
+ # EXCLUDED with reason, RESEARCH.md §3): the contrib
475
+ # opentelemetry-instrumentation-logger bridges the SAME `Logger#add`
476
+ # seam foam's LoggerBridge owns (lazy-block contract, both loop
477
+ # guards, logs-slot gating — logger_bridge.rb). If an FDE bundles it,
478
+ # the bundle-driven sweep below would install it BESIDE foam's prepend
479
+ # and every host log line would export twice (rule 12: never
480
+ # double-attach) — so the sweep pins it disabled. An FDE who
481
+ # deliberately wants the contrib bridge instead passes its instance
482
+ # via `additional_instrumentations` (install_additional installs
483
+ # directly and never reads this map — the explicit choice wins).
484
+ SUPERSEDED_INSTRUMENTATION_CONFIG = {
485
+ "OpenTelemetry::Instrumentation::Logger" => { enabled: false }.freeze,
486
+ }.freeze
487
+
473
488
  # Tier-1/2 activation (rules 5-7): install every bundled official
474
489
  # opentelemetry-instrumentation-* gem, presence-checked by the gem
475
490
  # itself. The outbound loop guard (rule 24) is wired by pre-installing
@@ -505,7 +520,9 @@ module Foam
505
520
 
506
521
  configure_loop_guard(config)
507
522
  install_additional(additional)
508
- OpenTelemetry::Instrumentation.registry.install_all(FLOOR_INSTRUMENTATION_CONFIG)
523
+ OpenTelemetry::Instrumentation.registry.install_all(
524
+ FLOOR_INSTRUMENTATION_CONFIG.merge(SUPERSEDED_INSTRUMENTATION_CONFIG)
525
+ )
509
526
  Diagnostics.info("instrumentations installed: #{gems.length} bundled gem(s)")
510
527
  rescue StandardError => e
511
528
  Diagnostics.warn("instrumentation activation failed: #{e.class}: #{e.message}")
@@ -10,9 +10,14 @@ module Foam
10
10
  # stream_generate_content (the gem consumes the SSE stream INSIDE the
11
11
  # call and returns the collected events, so both seams yield the full
12
12
  # response synchronously). gen_ai.provider.name uses the semconv
13
- # well-known value for the Gemini API: "gcp.gemini".
13
+ # well-known value for the Gemini API: "gcp.gemini", and
14
+ # gen_ai.operation.name / the span name use "generate_content" — the
15
+ # semconv well-known operation for this API shape, exactly what the
16
+ # fleet's js shim and python's official google-genai instrumentor emit
17
+ # (GenAI-uniformity ruling 2026-07-26; aligned from "chat" in 1.2.1).
14
18
  module GeminiShim
15
19
  PROVIDER = "gcp.gemini"
20
+ OPERATION = "generate_content"
16
21
  SUPPORTED = Gem::Requirement.new(">= 4.0", "< 5")
17
22
 
18
23
  class << self
@@ -58,7 +63,7 @@ module Foam
58
63
  nil
59
64
  end
60
65
  LLM.request_attributes(
61
- provider: PROVIDER, operation: "chat",
66
+ provider: PROVIDER, operation: OPERATION,
62
67
  model: model,
63
68
  input_messages: payload[:contents] || payload["contents"],
64
69
  system_instructions: payload[:system_instruction] || payload["system_instruction"]
@@ -90,7 +95,9 @@ module Foam
90
95
  def generate_content(payload, server_sent_events: nil, &callback)
91
96
  Foam::Otel::LLM.observe(
92
97
  provider: GeminiShim::PROVIDER,
93
- request: Foam::Otel::LLM.safe_request(provider: GeminiShim::PROVIDER) do
98
+ operation: GeminiShim::OPERATION,
99
+ request: Foam::Otel::LLM.safe_request(provider: GeminiShim::PROVIDER,
100
+ operation: GeminiShim::OPERATION) do
94
101
  GeminiShim.request(self, payload)
95
102
  end,
96
103
  response_extractor: GeminiShim::RESPONSE
@@ -100,7 +107,9 @@ module Foam
100
107
  def stream_generate_content(payload, server_sent_events: nil, &callback)
101
108
  Foam::Otel::LLM.observe(
102
109
  provider: GeminiShim::PROVIDER,
103
- request: Foam::Otel::LLM.safe_request(provider: GeminiShim::PROVIDER) do
110
+ operation: GeminiShim::OPERATION,
111
+ request: Foam::Otel::LLM.safe_request(provider: GeminiShim::PROVIDER,
112
+ operation: GeminiShim::OPERATION) do
104
113
  GeminiShim.request(self, payload)
105
114
  end,
106
115
  response_extractor: GeminiShim::RESPONSE
@@ -73,15 +73,44 @@ module Foam
73
73
  )
74
74
  end
75
75
 
76
- # RubyLLM::Message list → plain [{role:, content:}] for the RAW
76
+ # RubyLLM::Message list → plain message hashes for the RAW
77
77
  # input.messages JSON (Content objects degrade to_s, never dropped).
78
+ # Agent-loop turns keep their tool activity (rule 8a/8c floor —
79
+ # tool names/arguments are activity signal): an assistant tool-call
80
+ # turn carries "tool_calls" (id/name/arguments verbatim) and a
81
+ # tool-result turn carries its "tool_call_id" linkage.
78
82
  def serialize_messages(messages)
79
83
  Array(messages).map do |message|
80
- {
84
+ entry = {
81
85
  "role" => (message.respond_to?(:role) ? message.role.to_s : "user"),
82
86
  "content" => content_of(message),
83
87
  }
88
+ tool_calls = serialize_tool_calls(message)
89
+ entry["tool_calls"] = tool_calls if tool_calls
90
+ tool_call_id = message.respond_to?(:tool_call_id) ? message.tool_call_id : nil
91
+ entry["tool_call_id"] = tool_call_id.to_s if tool_call_id && !tool_call_id.to_s.empty?
92
+ entry
93
+ end
94
+ rescue StandardError, SystemStackError
95
+ nil
96
+ end
97
+
98
+ # RubyLLM::Message#tool_calls is a Hash{id => ToolCall} — each call
99
+ # rides as {id:, name:, arguments:} verbatim. nil when absent/empty
100
+ # (the key is omitted, matching the non-agentic message shape).
101
+ def serialize_tool_calls(message)
102
+ return nil unless message.respond_to?(:tool_calls)
103
+
104
+ calls = message.tool_calls
105
+ calls = calls.values if calls.is_a?(Hash)
106
+ serialized = Array(calls).map do |call|
107
+ {
108
+ "id" => (call.respond_to?(:id) ? call.id : nil),
109
+ "name" => (call.respond_to?(:name) ? call.name : nil),
110
+ "arguments" => (call.respond_to?(:arguments) ? call.arguments : nil),
111
+ }
84
112
  end
113
+ serialized.empty? ? nil : serialized
85
114
  rescue StandardError, SystemStackError
86
115
  nil
87
116
  end
@@ -96,20 +125,26 @@ module Foam
96
125
  end
97
126
  end
98
127
 
99
- # The returned RubyLLM::Message → response attributes.
128
+ # The returned RubyLLM::Message → response attributes. A tool-call
129
+ # turn (the assistant asking for a tool run) keeps its tool names +
130
+ # arguments on the output side too — without them the agent loop's
131
+ # model turns are invisible activity.
100
132
  RESPONSE = lambda do |message|
101
133
  next nil unless message.respond_to?(:content)
102
134
 
103
135
  input_tokens = message.respond_to?(:input_tokens) ? message.input_tokens : nil
104
136
  output_tokens = message.respond_to?(:output_tokens) ? message.output_tokens : nil
137
+ output_message = {
138
+ "role" => (message.respond_to?(:role) ? message.role.to_s : "assistant"),
139
+ "content" => RubyLLMShim.content_of(message),
140
+ }
141
+ tool_calls = RubyLLMShim.serialize_tool_calls(message)
142
+ output_message["tool_calls"] = tool_calls if tool_calls
105
143
  LLM.response_attributes(
106
144
  model: message.respond_to?(:model_id) ? message.model_id : nil,
107
145
  input_tokens: input_tokens.is_a?(Numeric) ? input_tokens.to_i : nil,
108
146
  output_tokens: output_tokens.is_a?(Numeric) ? output_tokens.to_i : nil,
109
- output_messages: [{
110
- "role" => (message.respond_to?(:role) ? message.role.to_s : "assistant"),
111
- "content" => RubyLLMShim.content_of(message),
112
- }]
147
+ output_messages: [output_message]
113
148
  )
114
149
  end
115
150
 
@@ -9,6 +9,14 @@ module Foam
9
9
  # request-context architecture is gone; the init surface changed.
10
10
  # 1.0.1 (#80): HTTP-client instrumentation class resolved for the loop guard.
11
11
  # 1.0.2: FOAM_OTEL_TOKEN doc convention (token comment source change); drop-in.
12
- VERSION = "1.2.0"
12
+ # 1.2.1: review-fleet fixes. DELIBERATE WIRE CHANGE (GenAI-uniformity
13
+ # ruling): Gemini shim gen_ai.operation.name + span name "chat" →
14
+ # "generate_content", matching the js/python Gemini emissions. ruby_llm
15
+ # shim now captures agent-loop tool activity (tool_calls id/name/
16
+ # arguments + tool_call_id) in gen_ai input/output messages. Contrib
17
+ # -logger pinned disabled in the sweep (foam's LoggerBridge supersedes
18
+ # it — rule 12 double-bridge guard). Sidekiq + Faraday floor entries
19
+ # wire-proven (rule 8b); Sidekiq :link posture documented (GOTCHAS G16).
20
+ VERSION = "1.2.1"
13
21
  end
14
22
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foam-otel
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foam