verica-observability 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5a673e65acb78166ecd02e7e1f71e732b9b5a94266b13b5439847323a754d0fd
4
+ data.tar.gz: 6ad1429c62b84d071393c1c906852ec63d1d1c09e5bf9e003f74ae0efe0f779c
5
+ SHA512:
6
+ metadata.gz: 314db6f26e34ed191181439756db2e2a05c42be85cb39061d7fe9c18d396c6def8cf1f1cf33e6253e1ac79e7aa4fc17df030dd386ccd8a3bb6907bfc5bfb5586
7
+ data.tar.gz: 5c093204aee3f513664aa1b8b7ad64d2c1f63308fd209bbed5c7c03e2eaee1f4bf571071ddb4d8f629a52b6993826e2558d87a18d51ded69a7716652859cbf08
data/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # verica-observability
2
+
3
+ Two-line LLM tracing for [Verica](https://verica.app). The official `openai`
4
+ gem has no auto-instrumentation anywhere: this gem ships it.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ gem install verica-observability
10
+ ```
11
+
12
+ ## Use (official `openai` gem)
13
+
14
+ ```ruby
15
+ require 'verica'
16
+
17
+ Verica.init(token: ENV['VERICA_TOKEN'], endpoint: 'https://<your-verica-host>')
18
+ client = Verica.wrap_openai(OpenAI::Client.new)
19
+ # use `client` exactly like the original; chat completions are traced.
20
+ ```
21
+
22
+ ## Use (community `ruby-openai` gem, alexrudall)
23
+
24
+ The community [`ruby-openai`](https://github.com/alexrudall/ruby-openai) gem has
25
+ a different API than the official gem: `client.chat(parameters: { ... })`
26
+ returning a plain Hash. It gets its own wrapper.
27
+
28
+ ```ruby
29
+ require 'verica'
30
+
31
+ Verica.init(token: ENV['VERICA_TOKEN'], endpoint: 'https://<your-verica-host>')
32
+ client = Verica.wrap_ruby_openai(OpenAI::Client.new)
33
+
34
+ client.chat(parameters: {
35
+ model: 'gpt-4o-mini',
36
+ messages: [{ role: 'user', content: 'Hello!' }]
37
+ })
38
+ ```
39
+
40
+ Streaming calls (a `stream:` proc in `parameters`) pass through untouched and
41
+ are traced with request-side attributes only (the streamed response is not a
42
+ stable Hash to read the model or usage from).
43
+
44
+ Use `wrap_openai` for the official `openai` gem and `wrap_ruby_openai` for
45
+ `ruby-openai`. The two gems' APIs are not interchangeable, so neither are the
46
+ wrappers.
47
+
48
+ ## Use (RubyLLM)
49
+
50
+ With RubyLLM plus its thoughtbot OpenTelemetry instrumentation, `Verica.init`
51
+ alone is enough: the spans it emits are exported to Verica.
52
+
53
+ ## Serverless
54
+
55
+ Call `Verica.flush` (or `Verica.shutdown`) before the runtime freezes so the
56
+ span batch is exported.
57
+
58
+ ## Options
59
+
60
+ | Option / env var | Default | Notes |
61
+ | --------------------------------------------- | ---------- | ------------------------------- |
62
+ | `token:` / `VERICA_TOKEN` | (required) | ingest-scoped API token |
63
+ | `endpoint:` / `VERICA_ENDPOINT` | (required) | your Verica origin |
64
+ | `capture_content:` / `VERICA_CAPTURE_CONTENT` | `true` | send prompt/response content |
65
+ | `conversation_id:` | (none) | stamps `gen_ai.conversation.id` |
66
+ | `service_name:` / `OTEL_SERVICE_NAME` | `app` | resource service.name |
67
+ | `debug:` / `VERICA_DEBUG` | `false` | log export errors |
68
+
69
+ Fail-open by design: if the endpoint is down or the token is invalid, spans are
70
+ dropped and your app is never affected. Export errors are silent unless `debug`
71
+ is on.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verica
4
+ # Config resolution: options > env vars > defaults. Pure.
5
+ Config = Struct.new(:token, :endpoint, :capture_content, :conversation_id, :service_name, :debug,
6
+ keyword_init: true) do
7
+ def self.resolve(options, env)
8
+ token = (options[:token] || env['VERICA_TOKEN'] || '').to_s
9
+ raw_endpoint = (options[:endpoint] || env['VERICA_ENDPOINT'] || '').to_s
10
+ missing = []
11
+ missing << 'token' if token.empty?
12
+ missing << 'endpoint' if raw_endpoint.empty?
13
+ return [nil, missing] unless missing.empty?
14
+
15
+ capture = options[:capture_content]
16
+ capture = env.key?('VERICA_CAPTURE_CONTENT') ? %w[1 true].include?(env['VERICA_CAPTURE_CONTENT']) : true if capture.nil?
17
+
18
+ [new(
19
+ token: token,
20
+ endpoint: raw_endpoint.sub(%r{/+\z}, ''),
21
+ capture_content: capture,
22
+ conversation_id: options[:conversation_id],
23
+ service_name: options[:service_name] || env['OTEL_SERVICE_NAME'] || 'app',
24
+ debug: options[:debug].nil? ? %w[1 true].include?(env['VERICA_DEBUG']) : options[:debug]
25
+ ), []]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Verica
6
+ # The official `openai` gem has no auto-instrumentation, so Verica ships its
7
+ # own thin decorator: everything delegates to the real client; only
8
+ # chat.completions.create is intercepted to emit ONE gen_ai.* span (pinned
9
+ # semconv, the exact attributes the Verica normalizer accepts). Fail-open:
10
+ # instrumentation errors never reach the caller; provider errors always do.
11
+ class OpenAIWrapper
12
+ def initialize(client)
13
+ @client = client
14
+ end
15
+
16
+ def chat
17
+ ChatProxy.new(@client.chat)
18
+ end
19
+
20
+ def method_missing(name, ...)
21
+ @client.public_send(name, ...)
22
+ end
23
+
24
+ def respond_to_missing?(name, include_private = false)
25
+ @client.respond_to?(name, include_private) || super
26
+ end
27
+
28
+ class ChatProxy
29
+ def initialize(chat) = @chat = chat
30
+
31
+ def completions
32
+ CompletionsProxy.new(@chat.completions)
33
+ end
34
+
35
+ def method_missing(name, ...)
36
+ @chat.public_send(name, ...)
37
+ end
38
+
39
+ def respond_to_missing?(name, include_private = false)
40
+ @chat.respond_to?(name, include_private) || super
41
+ end
42
+ end
43
+
44
+ class CompletionsProxy
45
+ def initialize(completions) = @completions = completions
46
+
47
+ def create(**params)
48
+ span = safely do
49
+ tracer = OpenTelemetry.tracer_provider.tracer('verica-observability', Verica::VERSION)
50
+ tracer.start_span("chat #{params[:model]}", kind: :client)
51
+ end
52
+ begin
53
+ response = @completions.create(**params)
54
+ safely { annotate(span, params, response) } if span
55
+ response
56
+ rescue StandardError => e
57
+ safely { span&.status = OpenTelemetry::Trace::Status.error(e.message) }
58
+ raise
59
+ ensure
60
+ safely { span&.finish }
61
+ end
62
+ end
63
+
64
+ def method_missing(name, ...)
65
+ @completions.public_send(name, ...)
66
+ end
67
+
68
+ def respond_to_missing?(name, include_private = false)
69
+ @completions.respond_to?(name, include_private) || super
70
+ end
71
+
72
+ private
73
+
74
+ def annotate(span, params, response)
75
+ cfg = Verica.config
76
+ span.set_attribute('gen_ai.operation.name', 'chat')
77
+ span.set_attribute('gen_ai.provider.name', 'openai')
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
80
+
81
+ model = response.respond_to?(:model) ? response.model : nil
82
+ span.set_attribute('gen_ai.response.model', model) if model
83
+ usage = response.respond_to?(:usage) ? response.usage : nil
84
+ if usage
85
+ input = usage.respond_to?(:prompt_tokens) ? usage.prompt_tokens : nil
86
+ output = usage.respond_to?(:completion_tokens) ? usage.completion_tokens : nil
87
+ span.set_attribute('gen_ai.usage.input_tokens', input) if input
88
+ span.set_attribute('gen_ai.usage.output_tokens', output) if output
89
+ end
90
+
91
+ return unless cfg&.capture_content
92
+
93
+ span.set_attribute('gen_ai.input.messages', JSON.generate(params[:messages])) if params[:messages]
94
+ choices = response.respond_to?(:choices) ? response.choices : nil
95
+ message = choices&.first.respond_to?(:message) ? choices.first.message : nil
96
+ return unless message
97
+
98
+ span.set_attribute(
99
+ 'gen_ai.output.messages',
100
+ JSON.generate([{ role: message.role.to_s, content: message.content }])
101
+ )
102
+ end
103
+
104
+ def safely
105
+ yield
106
+ rescue StandardError
107
+ nil
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Verica
6
+ # The community `ruby-openai` gem (alexrudall) has a different API than the
7
+ # official `openai` gem: `client.chat(parameters: { model:, messages: })`
8
+ # returning a plain Hash (string keys) instead of typed objects. Both gems
9
+ # define OpenAI::Client, so an app uses one or the other; this thin decorator
10
+ # is the ruby-openai counterpart of OpenAIWrapper. Everything delegates to the
11
+ # real client; only `chat` is intercepted to emit ONE gen_ai.* span with the
12
+ # SAME pinned semconv the Verica normalizer accepts. Fail-open: instrumentation
13
+ # errors never reach the caller; provider errors always do.
14
+ class RubyOpenAIWrapper
15
+ def initialize(client)
16
+ @client = client
17
+ end
18
+
19
+ def chat(parameters: {})
20
+ model = parameters[:model] || parameters['model']
21
+ span = safely do
22
+ tracer = OpenTelemetry.tracer_provider.tracer('verica-observability', Verica::VERSION)
23
+ tracer.start_span("chat #{model}", kind: :client)
24
+ end
25
+ begin
26
+ response = @client.chat(parameters: parameters)
27
+ safely { annotate(span, parameters, response) } if span
28
+ response
29
+ rescue StandardError => e
30
+ safely { span&.status = OpenTelemetry::Trace::Status.error(e.message) }
31
+ raise
32
+ ensure
33
+ safely { span&.finish }
34
+ end
35
+ end
36
+
37
+ def method_missing(name, ...)
38
+ @client.public_send(name, ...)
39
+ end
40
+
41
+ def respond_to_missing?(name, include_private = false)
42
+ @client.respond_to?(name, include_private) || super
43
+ end
44
+
45
+ private
46
+
47
+ def annotate(span, parameters, response)
48
+ cfg = Verica.config
49
+ model = parameters[:model] || parameters['model']
50
+ messages = parameters[:messages] || parameters['messages']
51
+ streaming = parameters[:stream] || parameters['stream']
52
+
53
+ span.set_attribute('gen_ai.operation.name', 'chat')
54
+ span.set_attribute('gen_ai.provider.name', 'openai')
55
+ 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
57
+ span.set_attribute('gen_ai.input.messages', JSON.generate(messages)) if cfg&.capture_content && messages
58
+
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
62
+ return unless response.is_a?(Hash)
63
+
64
+ response_model = response['model']
65
+ span.set_attribute('gen_ai.response.model', response_model) if response_model
66
+ input_tokens = response.dig('usage', 'prompt_tokens')
67
+ output_tokens = response.dig('usage', 'completion_tokens')
68
+ span.set_attribute('gen_ai.usage.input_tokens', input_tokens) if input_tokens
69
+ span.set_attribute('gen_ai.usage.output_tokens', output_tokens) if output_tokens
70
+
71
+ return unless cfg&.capture_content
72
+
73
+ message = response.dig('choices', 0, 'message')
74
+ return unless message.is_a?(Hash)
75
+
76
+ span.set_attribute(
77
+ 'gen_ai.output.messages',
78
+ JSON.generate([{ role: message['role'].to_s, content: message['content'] }])
79
+ )
80
+ end
81
+
82
+ def safely
83
+ yield
84
+ rescue StandardError
85
+ nil
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verica
4
+ VERSION = '0.1.0'
5
+ end
data/lib/verica.rb ADDED
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'verica/version'
4
+ require 'verica/config'
5
+ require 'verica/openai_wrapper'
6
+ require 'verica/ruby_openai_wrapper'
7
+
8
+ # Fail-open by contract: nothing in this module ever raises into the host app.
9
+ module Verica
10
+ class << self
11
+ attr_reader :config
12
+
13
+ def init(**options)
14
+ if @initialized
15
+ warn '[verica] init called twice; ignoring the second call.'
16
+ return true
17
+ end
18
+
19
+ cfg, missing = Config.resolve(options, ENV)
20
+ if cfg.nil?
21
+ warn "[verica] missing #{missing.join(' and ')}; tracing is disabled."
22
+ return false
23
+ end
24
+
25
+ begin
26
+ require 'logger'
27
+ require 'opentelemetry/sdk'
28
+ require 'opentelemetry-exporter-otlp'
29
+
30
+ # Spec §5: export errors (401, network) must not spam the host app's
31
+ # logs; OTel Ruby reports them through OpenTelemetry.logger.
32
+ OpenTelemetry.logger = Logger.new(IO::NULL) unless cfg.debug
33
+
34
+ OpenTelemetry::SDK.configure do |c|
35
+ c.service_name = cfg.service_name
36
+ c.add_span_processor(
37
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
38
+ OpenTelemetry::Exporter::OTLP::Exporter.new(
39
+ endpoint: "#{cfg.endpoint}/v1/traces",
40
+ headers: { 'Authorization' => "Bearer #{cfg.token}" }
41
+ )
42
+ )
43
+ )
44
+ end
45
+ @config = cfg
46
+ @initialized = true
47
+ true
48
+ rescue StandardError => e
49
+ warn "[verica] init failed; tracing is disabled.#{cfg.debug ? " #{e.message}" : ''}"
50
+ false
51
+ end
52
+ end
53
+
54
+ # Wraps the official `openai` gem client so chat completions emit traces.
55
+ def wrap_openai(client)
56
+ OpenAIWrapper.new(client)
57
+ end
58
+
59
+ # Wraps the community `ruby-openai` gem (alexrudall) client so its
60
+ # `chat(parameters:)` calls emit traces. Its API and Hash responses differ
61
+ # from the official gem, so it needs its own wrapper (not interchangeable).
62
+ def wrap_ruby_openai(client)
63
+ RubyOpenAIWrapper.new(client)
64
+ end
65
+
66
+ def flush
67
+ OpenTelemetry.tracer_provider.force_flush if @initialized
68
+ rescue StandardError
69
+ nil
70
+ end
71
+
72
+ def shutdown
73
+ OpenTelemetry.tracer_provider.shutdown if @initialized
74
+ rescue StandardError
75
+ nil
76
+ ensure
77
+ @initialized = false
78
+ @config = nil
79
+ end
80
+ end
81
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: verica-observability
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Verica
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: opentelemetry-exporter-otlp
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.29'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.29'
27
+ - !ruby/object:Gem::Dependency
28
+ name: opentelemetry-sdk
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.5'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.5'
41
+ description: init(token) and your OpenAI calls land as evaluable traces in Verica.
42
+ email:
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - README.md
48
+ - lib/verica.rb
49
+ - lib/verica/config.rb
50
+ - lib/verica/openai_wrapper.rb
51
+ - lib/verica/ruby_openai_wrapper.rb
52
+ - lib/verica/version.rb
53
+ homepage: https://verica.app
54
+ licenses:
55
+ - MIT
56
+ metadata: {}
57
+ post_install_message:
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '3.1'
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubygems_version: 3.5.22
73
+ signing_key:
74
+ specification_version: 4
75
+ summary: Two-line LLM tracing for Verica.
76
+ test_files: []