verica-observability 0.1.6 → 0.1.7

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: 820f96678415298ec28ca60e281ae11030e9b00430f5e3dde551dac6f631ce1a
4
- data.tar.gz: 721b988d642ead26441fa349a1db35eb28033842e2c3f6ea65c6eab90152c692
3
+ metadata.gz: 816c57a94a8a82efc43e464165b84611981c76dc6a456eb6bd359804621beddc
4
+ data.tar.gz: 929106f12d8e32c03de0bd9427b9babe4cf5639c5159e31d6a7f34d3d1a32d89
5
5
  SHA512:
6
- metadata.gz: ce6fb5297710557c1a0fefc1ed8189737bd14fc5a7c6dc127fbd15002315f6e0da430a92d1675533acb8defedd4fc991f55d9fe2cfc821d3b760c94d4408e073
7
- data.tar.gz: f23bf257a139e816217cd75a43cfa4c4db8ba869806e3668c26f505f342304db9238144e643e7000b6e3895bfc0fda389b3e881732f8b7db133cc44714cff0ec
6
+ metadata.gz: e7242e9480405617939a9d4ab4109f5f1eb104046372009d100c458627b7f846987a34f59b0439a766d5b297244d4a11b46400062faf3cf72ea09b0f571e9f71
7
+ data.tar.gz: 832738007de7cab2d6ea750369609e0d94e08c5578477038018b7086df8cddcf93de574a0f7cabdff00f13d26d736855af5a61678f3bdbc7d9655c75eb16363b
data/README.md CHANGED
@@ -108,8 +108,12 @@ true)` and the wrapper injects `stream_options: { include_usage: true }` for you
108
108
  default because the extra usage chunk arrives with an empty `choices` array that
109
109
  some caller procs may not expect.
110
110
 
111
- Limitation: streamed tool-call deltas (`delta.tool_calls`) are not captured in
112
- this pass; text output, model, and usage are.
111
+ Tool calls are captured on both paths: a non-streamed response's
112
+ `message.tool_calls` land on the span's output message, and streamed
113
+ `delta.tool_calls` fragments are reassembled per index (arguments concatenated
114
+ in arrival order). Tool-call-only turns (content null, `tool_calls` present),
115
+ the shape of a tool-looping agent's intermediate turns, produce a span output
116
+ too, so those turns never show an empty output in Verica.
113
117
 
114
118
  ## Use (RubyLLM)
115
119
 
@@ -158,10 +158,12 @@ module Verica
158
158
  message = response.dig('choices', 0, 'message')
159
159
  return unless message.is_a?(Hash)
160
160
 
161
- span.set_attribute(
162
- 'gen_ai.output.messages',
163
- JSON.generate([{ role: message['role'].to_s, content: message['content'] }])
164
- )
161
+ # A tool-call-only turn has content null: still emitted, with tool_calls,
162
+ # so the trace's output is never empty when the model DID respond.
163
+ output = { role: message['role'].to_s, content: message['content'] }
164
+ tool_calls = pinned_tool_calls(message['tool_calls'])
165
+ output[:tool_calls] = tool_calls unless tool_calls.empty?
166
+ span.set_attribute('gen_ai.output.messages', JSON.generate([output]))
165
167
  end
166
168
 
167
169
  def annotate_from_stream(span, accumulator, cfg)
@@ -176,12 +178,32 @@ module Verica
176
178
  return unless cfg&.capture_content
177
179
 
178
180
  text = accumulator.content
179
- return if text.nil? || text.empty?
181
+ tool_calls = accumulator.tool_calls
182
+ return if (text.nil? || text.empty?) && tool_calls.empty?
180
183
 
181
- span.set_attribute(
182
- 'gen_ai.output.messages',
183
- JSON.generate([{ role: (accumulator.role || 'assistant').to_s, content: text }])
184
- )
184
+ # Tool-call-only turn: no content key (matches the n8n mapper; the
185
+ # normalizer's flattener drops empty text either way).
186
+ output = { role: (accumulator.role || 'assistant').to_s }
187
+ output[:content] = text unless text.nil? || text.empty?
188
+ output[:tool_calls] = tool_calls unless tool_calls.empty?
189
+ span.set_attribute('gen_ai.output.messages', JSON.generate([output]))
190
+ end
191
+
192
+ # Reduces the API's tool_calls entries to the pinned semconv shape the
193
+ # Verica normalizer accepts (engine `toolCallOf`): `{function: {name,
194
+ # arguments}}` with `arguments` passed through as the API returned it (a
195
+ # JSON string). Same shape the TS/n8n producers emit. Junk entries are
196
+ # skipped; provider ids are not part of the pinned shape.
197
+ def pinned_tool_calls(raw)
198
+ return [] unless raw.is_a?(Array)
199
+
200
+ raw.filter_map do |entry|
201
+ fn = entry.is_a?(Hash) ? entry['function'] : nil
202
+ name = fn.is_a?(Hash) ? fn['name'] : nil
203
+ next unless name.is_a?(String) && !name.empty?
204
+
205
+ { function: { name: name, arguments: fn['arguments'] } }
206
+ end
185
207
  end
186
208
 
187
209
  def safely
@@ -192,9 +214,11 @@ module Verica
192
214
 
193
215
  # Drains a ruby-openai stream (parsed Hashes, string keys) into the pieces
194
216
  # the span needs: the joined assistant text, the first non-nil model, the
195
- # assistant role from the first delta that carries one, and the usage Hash
196
- # from the final chunk (present only when the caller asks for it). Tool-call
197
- # deltas are intentionally ignored in this pass.
217
+ # assistant role from the first delta that carries one, the usage Hash from
218
+ # the final chunk (present only when the caller asks for it), and the
219
+ # reassembled tool calls. Tool-call deltas carry an `index`: id/type/
220
+ # function.name come from the first fragment that has them; the argument
221
+ # fragments concatenate in arrival order into one JSON string per index.
198
222
  class StreamAccumulator
199
223
  attr_reader :content, :model, :role, :usage
200
224
 
@@ -203,6 +227,7 @@ module Verica
203
227
  @model = nil
204
228
  @role = nil
205
229
  @usage = nil
230
+ @tool_call_parts = {}
206
231
  end
207
232
 
208
233
  def add(chunk)
@@ -214,10 +239,47 @@ module Verica
214
239
  @role ||= delta['role']
215
240
  fragment = delta['content']
216
241
  @content << fragment if fragment.is_a?(String)
242
+ add_tool_call_fragments(delta['tool_calls'])
217
243
  end
218
244
  usage = chunk['usage']
219
245
  @usage = usage if usage.is_a?(Hash)
220
246
  end
247
+
248
+ # The reassembled tool calls, ordered by index, in the pinned semconv
249
+ # shape (`{function: {name, arguments}}`, arguments as one JSON string).
250
+ # Indexes that never produced a function name are dropped.
251
+ def tool_calls
252
+ @tool_call_parts.keys.sort.filter_map do |index|
253
+ part = @tool_call_parts[index]
254
+ name = part['name']
255
+ next unless name.is_a?(String) && !name.empty?
256
+
257
+ { function: { name: name, arguments: part['arguments'] } }
258
+ end
259
+ end
260
+
261
+ private
262
+
263
+ def add_tool_call_fragments(fragments)
264
+ return unless fragments.is_a?(Array)
265
+
266
+ fragments.each do |fragment|
267
+ next unless fragment.is_a?(Hash)
268
+
269
+ index = fragment['index']
270
+ next unless index.is_a?(Integer)
271
+
272
+ part = (@tool_call_parts[index] ||= { 'arguments' => +'' })
273
+ part['id'] ||= fragment['id']
274
+ part['type'] ||= fragment['type']
275
+ fn = fragment['function']
276
+ next unless fn.is_a?(Hash)
277
+
278
+ part['name'] ||= fn['name']
279
+ args = fn['arguments']
280
+ part['arguments'] << args if args.is_a?(String)
281
+ end
282
+ end
221
283
  end
222
284
  end
223
285
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Verica
4
- VERSION = '0.1.6'
4
+ VERSION = '0.1.7'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: verica-observability
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Verica
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-05 00:00:00.000000000 Z
11
+ date: 2026-07-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: opentelemetry-exporter-otlp