verica-observability 0.1.4 → 0.1.5

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: 438dcb9a5e21879ac0078d2a16a33424936a998bf89abb80aafb5948581de805
4
- data.tar.gz: a3297755e16825869ab659f288d4b6ffe5dfbca5161880aecd103b106af7c68b
3
+ metadata.gz: 179e07f9889543da9092844457585650ecef766cc9cbc8fdf10225c1606e3e30
4
+ data.tar.gz: 99c8a02603324aa3c5b8fa14acd401483f8a0b8d67ef5f761d4785f7e37aed2d
5
5
  SHA512:
6
- metadata.gz: 7aa94440e9614e45bf317092481309c70751bcbc35e73ffa5eb2b75717ed77792186520c61f5cb6bc78bd42906ef297e034ae12e4dfc29d05c08adb6fc4f8d67
7
- data.tar.gz: 562391c9009fe1fdfc82cd6319b3a081202824bf6cd66363f1136548f880c5f652a18e090ceb3d49b9e0d1f92cee0b4fded2e6352fdca29d8012360832eb4571
6
+ metadata.gz: cb9b59ec8c73e36a93409399842b5554b54f0f9583d96583f219d1b0fa2c5fbd716e81fe791473bef6e317186bc083fded6e3f511e1a70cfde73216df154d9b8
7
+ data.tar.gz: 286212de59d6b2aba37cf9e0ee9a149968c44b9c0a426e367b8e13552d751458d57efb8e0b1ffaed5e530ef0255473bbca342e23fb8c1dac7eedb76f4d0e2c3d
data/README.md CHANGED
@@ -50,10 +50,67 @@ anthropic = Verica.wrap_openai_compatible(OpenAI::Client.new(
50
50
  anthropic.chat(parameters: { model: 'claude-sonnet-4', messages: [...] })
51
51
  ```
52
52
 
53
- `wrap_ruby_openai` is a back-compat alias of `wrap_openai_compatible`. Streaming
54
- calls (a `stream:` proc) pass through with request-side attributes only. Ruby has
53
+ `wrap_ruby_openai` is a back-compat alias of `wrap_openai_compatible`. Ruby has
55
54
  no native Gemini/Anthropic gem support; use the OpenAI-compatible endpoints above.
56
55
 
56
+ ## Per-request conversation id
57
+
58
+ Group the turns of one chat under a single `gen_ai.conversation.id` without a
59
+ global setting. `Verica.with_conversation(id)` sets a thread-local override that
60
+ both wrappers stamp on every span emitted inside the block; it takes precedence
61
+ over the global `conversation_id:` from `init`, restores the previous value on
62
+ exit (including when the block raises), and is safe to nest. `id` is coerced with
63
+ `to_s`; a nil or empty id emits no conversation attribute. In a Rails chatbot
64
+ controller:
65
+
66
+ ```ruby
67
+ class MessagesController < ApplicationController
68
+ def create
69
+ Verica.with_conversation("chat-#{conversation.id}") do
70
+ reply = openai.chat(parameters: {
71
+ model: 'gpt-4o-mini',
72
+ messages: conversation.to_openai_messages
73
+ })
74
+ # ...persist and render reply...
75
+ end
76
+ end
77
+ end
78
+ ```
79
+
80
+ `with_conversation` returns the block's value, so you can wrap an existing method
81
+ body with it and keep the return unchanged. Each thread carries its own override,
82
+ so concurrent requests never leak conversation ids into one another.
83
+
84
+ ## Streaming (`wrap_openai_compatible`)
85
+
86
+ Streaming calls (a `stream:` proc in `parameters`) are fully captured: the
87
+ wrapper accumulates the chunks as they flow to your proc, then annotates the span
88
+ with the joined assistant output and the response model. Your proc still receives
89
+ every chunk, in order, unchanged; instrumentation never interferes with the
90
+ stream.
91
+
92
+ Tokens (and therefore cost) are only available when the provider sends a final
93
+ usage chunk, which OpenAI does when you pass `stream_options: { include_usage:
94
+ true }`:
95
+
96
+ ```ruby
97
+ client.chat(parameters: {
98
+ model: 'gpt-4o-mini',
99
+ messages: [{ role: 'user', content: 'Hello!' }],
100
+ stream_options: { include_usage: true },
101
+ stream: proc { |chunk| print chunk.dig('choices', 0, 'delta', 'content') }
102
+ })
103
+ ```
104
+
105
+ If you'd rather not touch every call site, set `Verica.init(..., stream_usage:
106
+ true)` and the wrapper injects `stream_options: { include_usage: true }` for you
107
+ (merged into a private copy, never mutating your parameters hash). It is off by
108
+ default because the extra usage chunk arrives with an empty `choices` array that
109
+ some caller procs may not expect.
110
+
111
+ Limitation: streamed tool-call deltas (`delta.tool_calls`) are not captured in
112
+ this pass; text output, model, and usage are.
113
+
57
114
  ## Use (RubyLLM)
58
115
 
59
116
  With RubyLLM plus its thoughtbot OpenTelemetry instrumentation, `Verica.init`
@@ -66,13 +123,14 @@ span batch is exported.
66
123
 
67
124
  ## Options
68
125
 
69
- | Option / env var | Default | Notes |
70
- | --------------------------------------------- | ---------- | ------------------------------- |
71
- | `token:` / `VERICA_TOKEN` | (required) | ingest-scoped API token |
72
- | `capture_content:` / `VERICA_CAPTURE_CONTENT` | `true` | send prompt/response content |
73
- | `conversation_id:` | (none) | stamps `gen_ai.conversation.id` |
74
- | `service_name:` / `OTEL_SERVICE_NAME` | `app` | resource service.name |
75
- | `debug:` / `VERICA_DEBUG` | `false` | log export errors |
126
+ | Option / env var | Default | Notes |
127
+ | --------------------------------------------- | ---------- | ---------------------------------- |
128
+ | `token:` / `VERICA_TOKEN` | (required) | ingest-scoped API token |
129
+ | `capture_content:` / `VERICA_CAPTURE_CONTENT` | `true` | send prompt/response content |
130
+ | `conversation_id:` | (none) | stamps `gen_ai.conversation.id` |
131
+ | `service_name:` / `OTEL_SERVICE_NAME` | `app` | resource service.name |
132
+ | `stream_usage:` / `VERICA_STREAM_USAGE` | `false` | inject `stream_options` for tokens |
133
+ | `debug:` / `VERICA_DEBUG` | `false` | log export errors |
76
134
 
77
135
  Fail-open by design: if Verica is unreachable or the token is invalid, spans are
78
136
  dropped and your app is never affected. Export errors are silent unless `debug`
data/lib/verica/config.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module Verica
4
4
  # Config resolution: options > env vars > defaults. Pure.
5
5
  Config = Struct.new(:token, :endpoint, :capture_content, :conversation_id, :service_name, :debug,
6
- keyword_init: true) do
6
+ :stream_usage, keyword_init: true) do
7
7
  # Verica's hosted ingest endpoint. Override via the `endpoint:` option or the
8
8
  # VERICA_ENDPOINT env var for self-host or local dev. Only `token` is required.
9
9
  DEFAULT_ENDPOINT = 'https://ingest.verica.app'
@@ -22,7 +22,12 @@ module Verica
22
22
  capture_content: capture,
23
23
  conversation_id: options[:conversation_id],
24
24
  service_name: options[:service_name] || env['OTEL_SERVICE_NAME'] || 'app',
25
- debug: options[:debug].nil? ? %w[1 true].include?(env['VERICA_DEBUG']) : options[:debug]
25
+ debug: options[:debug].nil? ? %w[1 true].include?(env['VERICA_DEBUG']) : options[:debug],
26
+ # Opt-in: append `stream_options: { include_usage: true }` to streaming
27
+ # calls so the provider emits a final usage chunk (tokens + cost). Off by
28
+ # default because it adds a trailing empty-choices chunk some caller procs
29
+ # may not expect.
30
+ stream_usage: options[:stream_usage].nil? ? %w[1 true].include?(env['VERICA_STREAM_USAGE']) : options[:stream_usage]
26
31
  ), []]
27
32
  end
28
33
  end
@@ -76,7 +76,8 @@ module Verica
76
76
  span.set_attribute('gen_ai.operation.name', 'chat')
77
77
  span.set_attribute('gen_ai.provider.name', Verica::Providers.for_model(params[:model]))
78
78
  span.set_attribute('gen_ai.request.model', params[:model].to_s) if params[:model]
79
- span.set_attribute('gen_ai.conversation.id', cfg.conversation_id) if cfg&.conversation_id
79
+ conversation_id = Verica.conversation_id
80
+ span.set_attribute('gen_ai.conversation.id', conversation_id) if conversation_id
80
81
 
81
82
  model = response.respond_to?(:model) ? response.model : nil
82
83
  span.set_attribute('gen_ai.response.model', model) if model
@@ -22,9 +22,27 @@ module Verica
22
22
  tracer = OpenTelemetry.tracer_provider.tracer('verica-observability', Verica::VERSION)
23
23
  tracer.start_span("chat #{model}", kind: :client)
24
24
  end
25
+
26
+ # Streaming: ruby-openai delivers each chunk to the caller's proc and the
27
+ # returned value is not a stable Hash. We wrap that proc so our accumulator
28
+ # sees every chunk first, then hand it to the caller unchanged. The HTTP
29
+ # call is synchronous, so by the time `@client.chat` returns the stream has
30
+ # fully drained and the accumulator is complete.
31
+ accumulator = nil
32
+ call_parameters = parameters
33
+ stream_key = stream_key_for(parameters)
34
+ if stream_key && parameters[stream_key].respond_to?(:call)
35
+ acc = StreamAccumulator.new
36
+ built = safely { build_streaming_parameters(parameters, stream_key, acc) }
37
+ if built
38
+ accumulator = acc
39
+ call_parameters = built
40
+ end
41
+ end
42
+
25
43
  begin
26
- response = @client.chat(parameters: parameters)
27
- safely { annotate(span, parameters, response) } if span
44
+ response = @client.chat(parameters: call_parameters)
45
+ safely { annotate(span, parameters, response, accumulator) } if span
28
46
  response
29
47
  rescue StandardError => e
30
48
  safely { span&.status = OpenTelemetry::Trace::Status.error(e.message) }
@@ -44,21 +62,60 @@ module Verica
44
62
 
45
63
  private
46
64
 
47
- def annotate(span, parameters, response)
65
+ def stream_key_for(parameters)
66
+ if parameters.key?(:stream)
67
+ :stream
68
+ elsif parameters.key?('stream')
69
+ 'stream'
70
+ end
71
+ end
72
+
73
+ # Returns a shallow copy of the caller's parameters with the stream proc
74
+ # replaced by our wrapper (which accumulates then delegates). Never mutates
75
+ # the caller's hash. When `stream_usage` is on, also injects
76
+ # `stream_options: { include_usage: true }` so the provider sends a final
77
+ # usage chunk, without mutating any nested hash the caller passed.
78
+ def build_streaming_parameters(parameters, stream_key, accumulator)
79
+ user_proc = parameters[stream_key]
80
+ wrapped = proc do |*args|
81
+ safely { accumulator.add(args.first) }
82
+ user_proc.call(*args)
83
+ end
84
+
85
+ copy = parameters.dup
86
+ copy[stream_key] = wrapped
87
+
88
+ if Verica.config&.stream_usage
89
+ symbols = stream_key.is_a?(Symbol)
90
+ opts_key = symbols ? :stream_options : 'stream_options'
91
+ usage_key = symbols ? :include_usage : 'include_usage'
92
+ existing = copy[opts_key]
93
+ merged = (existing.is_a?(Hash) ? existing.dup : {})
94
+ merged[usage_key] = true
95
+ copy[opts_key] = merged
96
+ end
97
+
98
+ copy
99
+ end
100
+
101
+ def annotate(span, parameters, response, accumulator = nil)
48
102
  cfg = Verica.config
49
103
  model = parameters[:model] || parameters['model']
50
104
  messages = parameters[:messages] || parameters['messages']
51
- streaming = parameters[:stream] || parameters['stream']
52
105
 
53
106
  span.set_attribute('gen_ai.operation.name', 'chat')
54
107
  span.set_attribute('gen_ai.provider.name', Verica::Providers.for_model(model))
55
108
  span.set_attribute('gen_ai.request.model', model.to_s) if model
56
- span.set_attribute('gen_ai.conversation.id', cfg.conversation_id) if cfg&.conversation_id
109
+ conversation_id = Verica.conversation_id
110
+ span.set_attribute('gen_ai.conversation.id', conversation_id) if conversation_id
57
111
  span.set_attribute('gen_ai.input.messages', JSON.generate(messages)) if cfg&.capture_content && messages
58
112
 
59
- # Streaming: chunks go to the caller's proc; the returned value is not a
60
- # stable Hash, so skip ALL response-side annotation.
61
- return if streaming
113
+ # Streaming: annotate the response side from the drained accumulator.
114
+ return annotate_from_stream(span, accumulator, cfg) if accumulator
115
+
116
+ # A stream proc we could not wrap (e.g. accumulator build failed): the
117
+ # returned value is not a stable Hash, so skip response-side annotation.
118
+ return if stream_key_for(parameters)
62
119
  return unless response.is_a?(Hash)
63
120
 
64
121
  response_model = response['model']
@@ -79,10 +136,60 @@ module Verica
79
136
  )
80
137
  end
81
138
 
139
+ def annotate_from_stream(span, accumulator, cfg)
140
+ span.set_attribute('gen_ai.response.model', accumulator.model) if accumulator.model
141
+ if accumulator.usage.is_a?(Hash)
142
+ input_tokens = accumulator.usage['prompt_tokens']
143
+ output_tokens = accumulator.usage['completion_tokens']
144
+ span.set_attribute('gen_ai.usage.input_tokens', input_tokens) if input_tokens
145
+ span.set_attribute('gen_ai.usage.output_tokens', output_tokens) if output_tokens
146
+ end
147
+
148
+ return unless cfg&.capture_content
149
+
150
+ text = accumulator.content
151
+ return if text.nil? || text.empty?
152
+
153
+ span.set_attribute(
154
+ 'gen_ai.output.messages',
155
+ JSON.generate([{ role: (accumulator.role || 'assistant').to_s, content: text }])
156
+ )
157
+ end
158
+
82
159
  def safely
83
160
  yield
84
161
  rescue StandardError
85
162
  nil
86
163
  end
164
+
165
+ # Drains a ruby-openai stream (parsed Hashes, string keys) into the pieces
166
+ # the span needs: the joined assistant text, the first non-nil model, the
167
+ # assistant role from the first delta that carries one, and the usage Hash
168
+ # from the final chunk (present only when the caller asks for it). Tool-call
169
+ # deltas are intentionally ignored in this pass.
170
+ class StreamAccumulator
171
+ attr_reader :content, :model, :role, :usage
172
+
173
+ def initialize
174
+ @content = +''
175
+ @model = nil
176
+ @role = nil
177
+ @usage = nil
178
+ end
179
+
180
+ def add(chunk)
181
+ return unless chunk.is_a?(Hash)
182
+
183
+ @model ||= chunk['model']
184
+ delta = chunk.dig('choices', 0, 'delta')
185
+ if delta.is_a?(Hash)
186
+ @role ||= delta['role']
187
+ fragment = delta['content']
188
+ @content << fragment if fragment.is_a?(String)
189
+ end
190
+ usage = chunk['usage']
191
+ @usage = usage if usage.is_a?(Hash)
192
+ end
193
+ end
87
194
  end
88
195
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Verica
4
- VERSION = '0.1.4'
4
+ VERSION = '0.1.5'
5
5
  end
data/lib/verica.rb CHANGED
@@ -8,9 +8,41 @@ require 'verica/ruby_openai_wrapper'
8
8
 
9
9
  # Fail-open by contract: nothing in this module ever raises into the host app.
10
10
  module Verica
11
+ # Fiber-local key holding the per-request conversation override. A value of
12
+ # `nil` means "no override active" (fall back to the global config); any
13
+ # String (including '') means an override IS active for this fiber/thread.
14
+ CONVERSATION_KEY = :verica_conversation_override
15
+
11
16
  class << self
12
17
  attr_reader :config
13
18
 
19
+ # Scopes `gen_ai.conversation.id` to a single request/thread, overriding the
20
+ # global `conversation_id:` from `init`. Thread-safe (fiber-local storage),
21
+ # nestable, and restored on the way out even if the block raises. `id` is
22
+ # coerced with `to_s`; nil/empty means "no conversation attribute" for spans
23
+ # emitted inside the block. Returns the block's value.
24
+ #
25
+ # Verica.with_conversation("chat-#{conversation.id}") do
26
+ # client.chat(parameters: { ... })
27
+ # end
28
+ def with_conversation(id)
29
+ previous = Thread.current[CONVERSATION_KEY]
30
+ Thread.current[CONVERSATION_KEY] = id.to_s
31
+ yield
32
+ ensure
33
+ Thread.current[CONVERSATION_KEY] = previous
34
+ end
35
+
36
+ # Resolves the conversation id both wrappers stamp on spans: the thread-local
37
+ # override (when a `with_conversation` block is active) wins over the global
38
+ # config; nil/empty resolves to nil so the attribute is omitted.
39
+ def conversation_id
40
+ override = Thread.current[CONVERSATION_KEY]
41
+ raw = override.nil? ? @config&.conversation_id : override
42
+ value = raw.to_s
43
+ value.empty? ? nil : value
44
+ end
45
+
14
46
  def init(**options)
15
47
  if @initialized
16
48
  warn '[verica] init called twice; ignoring the second call.'
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.4
4
+ version: 0.1.5
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-03 00:00:00.000000000 Z
11
+ date: 2026-07-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: opentelemetry-exporter-otlp