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 +4 -4
- data/README.md +67 -9
- data/lib/verica/config.rb +7 -2
- data/lib/verica/openai_wrapper.rb +2 -1
- data/lib/verica/ruby_openai_wrapper.rb +115 -8
- data/lib/verica/version.rb +1 -1
- data/lib/verica.rb +32 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 179e07f9889543da9092844457585650ecef766cc9cbc8fdf10225c1606e3e30
|
|
4
|
+
data.tar.gz: 99c8a02603324aa3c5b8fa14acd401483f8a0b8d67ef5f761d4785f7e37aed2d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`.
|
|
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
|
-
| `
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
60
|
-
|
|
61
|
-
|
|
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
|
data/lib/verica/version.rb
CHANGED
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
|
+
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-
|
|
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
|