Module: Verica

Defined in:
lib/verica.rb,
lib/verica/config.rb,
lib/verica/version.rb,
lib/verica/providers.rb,
lib/verica/openai_wrapper.rb,
lib/verica/ruby_openai_wrapper.rb

Overview

Fail-open by contract: nothing in this module ever raises into the host app.

Defined Under Namespace

Modules: Providers Classes: Config, OpenAIWrapper, RubyOpenAIWrapper

Constant Summary collapse

CONVERSATION_KEY =

Fiber-local key holding the per-request conversation override. A value of nil means "no override active" (fall back to the global config); any String (including '') means an override IS active for this fiber/thread.

:verica_conversation_override
TAGS_KEY =

Fiber-local key holding the per-request tag scope (Array); nil means no scope is active. Scopes UNION with the global config tags.

:verica_tags_scope
VERSION =
'0.1.9'

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.configObject (readonly)

Returns the value of attribute config.



22
23
24
# File 'lib/verica.rb', line 22

def config
  @config
end

Class Method Details

.conversation_idObject

Resolves the conversation id both wrappers stamp on spans: the thread-local override (when a with_conversation block is active) wins over the global config; nil/empty resolves to nil so the attribute is omitted.



44
45
46
47
48
49
# File 'lib/verica.rb', line 44

def conversation_id
  override = Thread.current[CONVERSATION_KEY]
  raw = override.nil? ? @config&.conversation_id : override
  value = raw.to_s
  value.empty? ? nil : value
end

.current_tagsObject

Global config tags ∪ the active thread-local scope, deduped in order.



68
69
70
# File 'lib/verica.rb', line 68

def current_tags
  merge_tags(@config&.tags || [], Thread.current[TAGS_KEY] || [])
end

.flushObject



149
150
151
152
153
# File 'lib/verica.rb', line 149

def flush
  OpenTelemetry.tracer_provider.force_flush if @initialized
rescue StandardError
  nil
end

.init(**options) ⇒ Object



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/verica.rb', line 93

def init(**options)
  if @initialized
    warn '[verica] init called twice; ignoring the second call.'
    return true
  end

  cfg, missing = Config.resolve(options, ENV)
  if cfg.nil?
    warn "[verica] missing #{missing.join(' and ')}; tracing is disabled."
    return false
  end

  begin
    require 'logger'
    require 'opentelemetry/sdk'
    require 'opentelemetry-exporter-otlp'

    # Spec §5: export errors (401, network) must not spam the host app's
    # logs; OTel Ruby reports them through OpenTelemetry.logger.
    OpenTelemetry.logger = Logger.new(IO::NULL) unless cfg.debug

    OpenTelemetry::SDK.configure do |c|
      c.service_name = cfg.service_name
      c.add_span_processor(
        OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
          OpenTelemetry::Exporter::OTLP::Exporter.new(
            endpoint: "#{cfg.endpoint}/v1/traces",
            headers: { 'Authorization' => "Bearer #{cfg.token}" }
          )
        )
      )
    end
    @config = cfg
    @initialized = true
    true
  rescue StandardError => e
    warn "[verica] init failed; tracing is disabled.#{cfg.debug ? " #{e.message}" : ''}"
    false
  end
end

.shutdownObject



155
156
157
158
159
160
161
162
# File 'lib/verica.rb', line 155

def shutdown
  OpenTelemetry.tracer_provider.shutdown if @initialized
rescue StandardError
  nil
ensure
  @initialized = false
  @config = nil
end

.with_conversation(id) ⇒ Object

Scopes gen_ai.conversation.id to a single request/thread, overriding the global conversation_id: from init. Thread-safe (fiber-local storage), nestable, and restored on the way out even if the block raises. id is coerced with to_s; nil/empty means "no conversation attribute" for spans emitted inside the block. Returns the block's value.

Verica.with_conversation("chat-#{conversation.id}") do
client.chat(parameters: { ... })
end


33
34
35
36
37
38
39
# File 'lib/verica.rb', line 33

def with_conversation(id)
  previous = Thread.current[CONVERSATION_KEY]
  Thread.current[CONVERSATION_KEY] = id.to_s
  yield
ensure
  Thread.current[CONVERSATION_KEY] = previous
end

.with_span(name, &block) ⇒ Object

Opens name as the active span for the block: wrapped-client calls and nested with_span/with_tool inside become children, so a whole agent run lands as ONE trace with a span tree. The span name is name verbatim (what span_check patterns match). Exceptions mark the span as error and propagate untouched. No-op before init (fail-open). Returns the block's value.

Verica.with_span("agent.run") { ...loop... }


80
81
82
# File 'lib/verica.rb', line 80

def with_span(name, &block)
  manual_span(name.to_s, {}, &block)
end

.with_tags(tags) ⇒ Object

Adds tags to every span emitted inside the block, UNIONED with the global tags: from init (global first, dedup, to_s coercion). Thread-safe (fiber-local), nestable (nested scopes accumulate), restored on the way out even if the block raises. Returns the block's value.

Verica.with_tags(["mentor-chat"]) do
client.chat(parameters: { ... })
end


59
60
61
62
63
64
65
# File 'lib/verica.rb', line 59

def with_tags(tags)
  previous = Thread.current[TAGS_KEY]
  Thread.current[TAGS_KEY] = merge_tags(previous || [], tags)
  yield
ensure
  Thread.current[TAGS_KEY] = previous
end

.with_tool(name, &block) ⇒ Object

with_span + the OTel GenAI semconv tool attributes (gen_ai.operation.name = execute_tool, gen_ai.tool.name).

Verica.with_tool("search_exercises") { execute(...) }


88
89
90
91
# File 'lib/verica.rb', line 88

def with_tool(name, &block)
  n = name.to_s
  manual_span(n, { 'gen_ai.operation.name' => 'execute_tool', 'gen_ai.tool.name' => n }, &block)
end

.wrap_openai(client) ⇒ Object

Wraps the official openai gem client so chat completions emit traces.



135
136
137
# File 'lib/verica.rb', line 135

def wrap_openai(client)
  OpenAIWrapper.new(client)
end

.wrap_openai_compatible(client) ⇒ Object Also known as: wrap_ruby_openai

Wraps a client that speaks the community ruby-openai gem's chat(parameters:) API, so its calls emit traces. This is the path for any OpenAI-compatible endpoint: OpenAI itself, plus Gemini and Anthropic via their OpenAI-compatible endpoints (the provider is inferred from the model). Preferred name; wrap_ruby_openai is a back-compat alias.



144
145
146
# File 'lib/verica.rb', line 144

def wrap_openai_compatible(client)
  RubyOpenAIWrapper.new(client)
end