riffer 0.32.1 → 0.34.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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +39 -0
  5. data/README.md +13 -11
  6. data/docs/01_OVERVIEW.md +2 -0
  7. data/docs/04_AGENT_LIFECYCLE.md +15 -13
  8. data/docs/08_MESSAGES.md +39 -5
  9. data/docs/09_STREAM_EVENTS.md +14 -0
  10. data/docs/10_CONFIGURATION.md +73 -4
  11. data/docs/13_SKILLS.md +62 -4
  12. data/docs/14_MCP.md +2 -1
  13. data/docs/16_TRACING.md +274 -0
  14. data/docs/17_METRICS.md +153 -0
  15. data/docs/providers/07_CUSTOM_PROVIDERS.md +44 -0
  16. data/lib/riffer/agent/response.rb +11 -2
  17. data/lib/riffer/agent/run.rb +136 -35
  18. data/lib/riffer/agent.rb +1 -1
  19. data/lib/riffer/config.rb +230 -15
  20. data/lib/riffer/guardrail.rb +8 -0
  21. data/lib/riffer/guardrails/runner.rb +58 -0
  22. data/lib/riffer/helpers/boolean.rb +22 -0
  23. data/lib/riffer/mcp/authenticated_tool.rb +14 -20
  24. data/lib/riffer/mcp/registration.rb +4 -4
  25. data/lib/riffer/mcp/tool.rb +23 -0
  26. data/lib/riffer/mcp/tool_factory.rb +14 -22
  27. data/lib/riffer/messages/assistant.rb +15 -3
  28. data/lib/riffer/messages/base.rb +2 -1
  29. data/lib/riffer/metrics/instruments.rb +31 -0
  30. data/lib/riffer/metrics/no_op.rb +14 -0
  31. data/lib/riffer/metrics/otel.rb +80 -0
  32. data/lib/riffer/metrics.rb +94 -0
  33. data/lib/riffer/providers/amazon_bedrock.rb +57 -21
  34. data/lib/riffer/providers/anthropic.rb +59 -24
  35. data/lib/riffer/providers/azure_open_ai.rb +7 -0
  36. data/lib/riffer/providers/base.rb +247 -15
  37. data/lib/riffer/providers/finish_reason.rb +27 -0
  38. data/lib/riffer/providers/gemini.rb +59 -11
  39. data/lib/riffer/providers/mock.rb +30 -9
  40. data/lib/riffer/providers/open_ai.rb +78 -24
  41. data/lib/riffer/providers/open_router.rb +56 -16
  42. data/lib/riffer/providers/repository.rb +9 -0
  43. data/lib/riffer/providers/token_usage.rb +27 -11
  44. data/lib/riffer/skills/activate_tool.rb +11 -2
  45. data/lib/riffer/skills/adapter.rb +15 -0
  46. data/lib/riffer/skills/context.rb +63 -11
  47. data/lib/riffer/skills/markdown_adapter.rb +1 -1
  48. data/lib/riffer/skills/xml_adapter.rb +1 -1
  49. data/lib/riffer/stream_events/finish_reason_done.rb +34 -0
  50. data/lib/riffer/tools/runtime.rb +99 -3
  51. data/lib/riffer/tracing/capture.rb +92 -0
  52. data/lib/riffer/tracing/no_op.rb +61 -0
  53. data/lib/riffer/tracing/otel.rb +132 -0
  54. data/lib/riffer/tracing/stream_recorder.rb +51 -0
  55. data/lib/riffer/tracing.rb +78 -0
  56. data/lib/riffer/version.rb +1 -1
  57. data/sig/_private/opentelemetry.rbs +22 -0
  58. data/sig/generated/riffer/agent/response.rbs +9 -2
  59. data/sig/generated/riffer/agent/run.rbs +28 -8
  60. data/sig/generated/riffer/config.rbs +162 -16
  61. data/sig/generated/riffer/guardrail.rbs +6 -0
  62. data/sig/generated/riffer/guardrails/runner.rbs +22 -0
  63. data/sig/generated/riffer/helpers/boolean.rbs +11 -0
  64. data/sig/generated/riffer/mcp/authenticated_tool.rbs +6 -8
  65. data/sig/generated/riffer/mcp/registration.rbs +4 -4
  66. data/sig/generated/riffer/mcp/tool.rbs +19 -0
  67. data/sig/generated/riffer/mcp/tool_factory.rbs +8 -7
  68. data/sig/generated/riffer/messages/assistant.rbs +10 -4
  69. data/sig/generated/riffer/metrics/instruments.rbs +15 -0
  70. data/sig/generated/riffer/metrics/no_op.rbs +10 -0
  71. data/sig/generated/riffer/metrics/otel.rbs +48 -0
  72. data/sig/generated/riffer/metrics.rbs +72 -0
  73. data/sig/generated/riffer/providers/amazon_bedrock.rbs +35 -14
  74. data/sig/generated/riffer/providers/anthropic.rbs +41 -20
  75. data/sig/generated/riffer/providers/azure_open_ai.rbs +5 -0
  76. data/sig/generated/riffer/providers/base.rbs +78 -2
  77. data/sig/generated/riffer/providers/finish_reason.rbs +19 -0
  78. data/sig/generated/riffer/providers/gemini.rbs +25 -2
  79. data/sig/generated/riffer/providers/mock.rbs +16 -5
  80. data/sig/generated/riffer/providers/open_ai.rbs +44 -22
  81. data/sig/generated/riffer/providers/open_router.rbs +31 -12
  82. data/sig/generated/riffer/providers/repository.rbs +7 -0
  83. data/sig/generated/riffer/providers/token_usage.rbs +20 -10
  84. data/sig/generated/riffer/skills/activate_tool.rbs +7 -1
  85. data/sig/generated/riffer/skills/adapter.rbs +10 -0
  86. data/sig/generated/riffer/skills/context.rbs +42 -4
  87. data/sig/generated/riffer/stream_events/finish_reason_done.rbs +21 -0
  88. data/sig/generated/riffer/tools/runtime.rbs +35 -0
  89. data/sig/generated/riffer/tracing/capture.rbs +46 -0
  90. data/sig/generated/riffer/tracing/no_op.rbs +46 -0
  91. data/sig/generated/riffer/tracing/otel.rbs +84 -0
  92. data/sig/generated/riffer/tracing/stream_recorder.rbs +31 -0
  93. data/sig/generated/riffer/tracing.rbs +52 -0
  94. data/sig/manual/riffer/helpers/boolean.rbs +5 -0
  95. data/sig/manual/riffer/metrics/no_op.rbs +5 -0
  96. data/sig/manual/riffer/metrics.rbs +5 -0
  97. data/sig/manual/riffer/providers.rbs +9 -0
  98. data/sig/manual/riffer/tracing/capture.rbs +5 -0
  99. data/sig/manual/riffer/tracing/no_op.rbs +5 -0
  100. data/sig/manual/riffer/tracing.rbs +5 -0
  101. metadata +42 -6
@@ -0,0 +1,274 @@
1
+ # Tracing
2
+
3
+ Riffer instruments its agent loop with [OpenTelemetry](https://opentelemetry.io/) spans, following the [GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/). The emitted span shape — names, attributes, and hierarchy — is a public, versioned contract you can build dashboards, alerts, and cost reporting against. This page is the reference for that contract.
4
+
5
+ Riffer only _emits_ spans, and only through a backend you assign to `config.tracing.backend` — OpenTelemetry is the built-in option you opt into (the host application owns the SDK, exporter, sampling, and service naming — the standard OTEL split), but never a default. With no backend assigned, every span is a silent no-op and Riffer carries no OpenTelemetry gem dependency.
6
+
7
+ ## Enabling tracing
8
+
9
+ Riffer emits spans only through a backend you assign to `config.tracing.backend` — it does **not** auto-detect OpenTelemetry. To use OTEL, add the SDK, configure an exporter, and assign Riffer's built-in OTEL backend with `Riffer::Tracing::Otel.build`.
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem "opentelemetry-sdk"
14
+ ```
15
+
16
+ ```ruby
17
+ require "opentelemetry/sdk"
18
+
19
+ OpenTelemetry::SDK.configure do |c|
20
+ c.service_name = "my-agent-host"
21
+ end
22
+
23
+ Riffer.configure do |config|
24
+ config.tracing.backend = Riffer::Tracing::Otel.build
25
+ end
26
+ ```
27
+
28
+ `Riffer::Tracing::Otel.build` wraps the global `OpenTelemetry.tracer_provider` by default; pass `provider:` to wrap a specific one (an in-memory provider in tests, say). It returns `nil` — leaving tracing a no-op rather than raising — when the `opentelemetry-api` gem is absent or outside the supported range (>= 1.1, < 2), so the same configuration is safe on a host that doesn't bundle OTEL.
29
+
30
+ To see Riffer's spans on stdout while developing locally, wire in the console exporter:
31
+
32
+ ```ruby
33
+ require "opentelemetry/sdk"
34
+
35
+ OpenTelemetry::SDK.configure do |c|
36
+ c.service_name = "my-agent-host"
37
+ c.add_span_processor(
38
+ OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(
39
+ OpenTelemetry::SDK::Trace::Export::ConsoleSpanExporter.new
40
+ )
41
+ )
42
+ end
43
+ ```
44
+
45
+ Any backend that implements the OpenTelemetry Traces API then ingests Riffer's spans with no second pipeline. For real exporter and collector setup (OTLP, sampling, resource attributes), see the [OpenTelemetry Ruby docs](https://opentelemetry.io/docs/languages/ruby/). A host on a non-OTEL stack (e.g. Datadog APM) assigns its own backend instead — see [Routing to a non-OpenTelemetry backend](#routing-to-a-non-opentelemetry-backend).
46
+
47
+ The tracing knobs — the `enabled` kill switch, opt-in message-content capture, and the `backend` itself — live in [Configuration — Tracing](10_CONFIGURATION.md#tracing).
48
+
49
+ Spans are emitted under the instrumentation scope named `riffer`, versioned with the Riffer gem version. That scope version is the runtime signal for which release produced a span; see [Stability](#stability).
50
+
51
+ ## Routing to a non-OpenTelemetry backend
52
+
53
+ OpenTelemetry is one backend, not the only one. A host already invested in another stack — Datadog APM, say — can route Riffer's spans into it with **no `opentelemetry-*` gem installed** by assigning its own backend to `config.tracing.backend` in place of `Riffer::Tracing::Otel.build`. Whatever you assign is the backend; there is no fallback and no auto-detection — an unset backend is a no-op.
54
+
55
+ ```ruby
56
+ Riffer.configure do |config|
57
+ config.tracing.backend = MyDatadogTracingBackend.new
58
+ end
59
+ ```
60
+
61
+ The backend is duck-typed — any object satisfying the contract works, and the setter validates only that it responds to `in_span` (otherwise it raises `Riffer::ArgumentError`). It must respond to:
62
+
63
+ - `in_span(name, attributes:, kind:) { |span| … }` — open a span around the block, yield a span object, and return the block's value.
64
+ - `current_context` — return the active trace context (for re-attaching across fiber/thread boundaries), or `nil` when there is none.
65
+ - `with_context(context) { … }` — run the block with the given context active; a `nil` context passes straight through, so a span re-attached while tracing was dark stays harmless.
66
+
67
+ The yielded span must respond to `set_attribute(key, value)`, `add_event(name, attributes:)`, `record_exception(exception)`, `error!(description)`, and `recording?` — the same surface the OTEL span exposes. `Riffer::Tracing::NoOp` is the reference shape for both the backend and the span contract. The `enabled` kill switch is still honoured ahead of the backend: with `config.tracing.enabled = false`, spans short-circuit to the no-op without ever reaching a custom backend.
68
+
69
+ ## Spans
70
+
71
+ Riffer emits four span types. A single agent run produces one `invoke_agent` span wrapping one `chat` span per model call, one `execute_tool` span per tool call, and one `execute_guardrail` span per guardrail execution, interleaved in execution order:
72
+
73
+ ```
74
+ invoke_agent {agent} INTERNAL
75
+ ├─ execute_guardrail {name} INTERNAL (one per before-phase guardrail)
76
+ ├─ chat {model} CLIENT (one per LLM call)
77
+ ├─ execute_tool {tool} INTERNAL (one per tool call)
78
+ │ └─ (host spans nest here via around_tool_call / tool internals)
79
+ ├─ execute_guardrail {name} INTERNAL (one per after-phase guardrail, after each response)
80
+ ├─ chat {model}
81
+ └─ …
82
+ ```
83
+
84
+ The `execute_tool` span opens _outside_ Riffer's `around_tool_call` hook, so any spans a host emits from that hook — or from inside the tool itself — nest beneath it. See [Advanced Tools](07_TOOL_ADVANCED.md) for the hook.
85
+
86
+ ### Reading the attribute tables
87
+
88
+ Every attribute a span can carry is listed below, including the conditional ones — you can't query a key you don't know exists. The **Present** column tells you when to expect each:
89
+
90
+ - **`Always`** — emitted on every span of that type.
91
+ - **`On <something happened>`** (e.g. `On a tripwire`, `On failure`) — _path-conditional_: presence is itself a signal. If `riffer.tripwire.phase` is set, a guardrail tripped. Filter on these with confidence.
92
+ - **`When the provider reports it`** / **`When the caller set it`** — _best-effort_: may be absent even on a perfectly healthy span, because it depends on what the upstream provider returned or what options the caller passed. Guard or coalesce these in queries.
93
+
94
+ The contract promise is: **when present**, a key carries the documented meaning and type. It is _not_ a promise that every key appears on every span.
95
+
96
+ ## `invoke_agent {agent}` — the run span
97
+
98
+ `INTERNAL`. One per call to `Agent#generate` or `Agent#stream`. The span name suffix is the agent's identifier (e.g. `invoke_agent weather-agent`).
99
+
100
+ | Attribute | Type | Present |
101
+ | ------------------------------------------ | ------ | ---------------------------------------------------- |
102
+ | `gen_ai.operation.name` | string | Always (`"invoke_agent"`) |
103
+ | `gen_ai.agent.name` | string | Always — the agent's identifier |
104
+ | `gen_ai.provider.name` | string | Always — see [provider names](#provider-names) |
105
+ | `gen_ai.request.model` | string | Always — the agent's configured model |
106
+ | `riffer.steps` | int | Always — number of LLM calls in the run |
107
+ | `gen_ai.usage.input_tokens` | int | When the run made an LLM call that reported usage |
108
+ | `gen_ai.usage.output_tokens` | int | When the run made an LLM call that reported usage |
109
+ | `gen_ai.usage.cache_read.input_tokens` | int | When the provider reported cache reads |
110
+ | `gen_ai.usage.cache_creation.input_tokens` | int | When the provider reported cache writes |
111
+ | `riffer.cost` | float | When every call in the run was priced |
112
+ | `riffer.interrupt.reason` | string | On interrupt (e.g. approval needed, max steps) |
113
+ | `riffer.tripwire.guardrail` | string | On a guardrail tripwire, when the guardrail is named |
114
+ | `riffer.tripwire.reason` | string | On a guardrail tripwire |
115
+ | `riffer.tripwire.phase` | string | On a guardrail tripwire (`"before"` / `"after"`) |
116
+ | `error.type` | string | On an unhandled exception |
117
+
118
+ The `riffer.tripwire.*` attributes are the run-level summary of the guardrail that halted the run; `riffer.tripwire.guardrail` carries the same name value as the blocking [`execute_guardrail`](#execute_guardrail-name--the-guardrail-span) span's `riffer.guardrail.name`, so the two join on a single key.
119
+
120
+ Usage on this span is the run total, aggregated across every step. See [Token usage](#token-usage) for the trap this creates.
121
+
122
+ ## `chat {model}` — the LLM call span
123
+
124
+ `CLIENT`. One per model call, in both `generate` and `stream`. The span name suffix is the model (e.g. `chat gpt-4`), or just `chat` when no model is set.
125
+
126
+ | Attribute | Type | Present |
127
+ | ------------------------------------------ | -------- | ----------------------------------------------------------------------------- |
128
+ | `gen_ai.operation.name` | string | Always (`"chat"`) |
129
+ | `gen_ai.provider.name` | string | Always — see [provider names](#provider-names) |
130
+ | `gen_ai.request.model` | string | When a model is set |
131
+ | `gen_ai.request.temperature` | float | When the caller set it |
132
+ | `gen_ai.request.max_tokens` | int | When the caller set `max_tokens` or `max_output_tokens` |
133
+ | `gen_ai.request.top_p` | float | When the caller set it |
134
+ | `gen_ai.request.top_k` | int | When the caller set it |
135
+ | `gen_ai.request.frequency_penalty` | float | When the caller set it |
136
+ | `gen_ai.request.presence_penalty` | float | When the caller set it |
137
+ | `gen_ai.request.seed` | int | When the caller set it |
138
+ | `gen_ai.request.stop_sequences` | string[] | When the caller set it |
139
+ | `gen_ai.usage.input_tokens` | int | When the provider reported usage |
140
+ | `gen_ai.usage.output_tokens` | int | When the provider reported usage |
141
+ | `gen_ai.usage.cache_read.input_tokens` | int | When the provider reported cache reads |
142
+ | `gen_ai.usage.cache_creation.input_tokens` | int | When the provider reported cache writes |
143
+ | `riffer.cost` | float | When the call's model was priced |
144
+ | `gen_ai.response.finish_reasons` | string[] | When the provider reported a finish reason |
145
+ | `riffer.finish_reason.raw` | string | When the raw value differs from the normalized one |
146
+ | `gen_ai.input.messages` | string | When `capture_messages` is on (JSON; see [capture](#message-content-capture)) |
147
+ | `gen_ai.system_instructions` | string | When `capture_messages` is on and a system prompt exists |
148
+ | `gen_ai.output.messages` | string | When `capture_messages` is on (JSON) |
149
+ | `error.type` | string | On an unhandled exception |
150
+
151
+ `gen_ai.response.finish_reasons` is an array of exactly one normalized value, from the fixed vocabulary `stop`, `length`, `tool_calls`, `content_filter`, `error`, `other`. When the provider's raw wire value carries more nuance than the normalized one, the raw string is preserved on `riffer.finish_reason.raw`.
152
+
153
+ ## `execute_tool {tool}` — the tool call span
154
+
155
+ `INTERNAL`. One per tool call dispatched by the runtime. The span name suffix is the tool's name (e.g. `execute_tool get_weather`).
156
+
157
+ | Attribute | Type | Present |
158
+ | ---------------------------- | ------ | ----------------------------------------------------------------------- |
159
+ | `gen_ai.operation.name` | string | Always (`"execute_tool"`) |
160
+ | `gen_ai.tool.name` | string | Always |
161
+ | `gen_ai.tool.call.id` | string | Always — the originating tool-call id |
162
+ | `error.type` | string | On a tool error (see below) |
163
+ | `gen_ai.tool.call.arguments` | string | When `capture_messages` is on (see [capture](#message-content-capture)) |
164
+ | `gen_ai.tool.call.result` | string | When `capture_messages` is on |
165
+
166
+ A tool failure comes in two shapes, distinguished by span status:
167
+
168
+ - **Handled error** — the tool returned an error response. `error.type` carries the category and the **span status stays unset** (the run continues). The framework's categories are `unknown_tool`, `validation_error`, `timeout_error`, and `execution_error`; a custom tool may set its own via `Riffer::Tools::Response.error(type:)`.
169
+ - **Unhandled exception** — the dispatch raised. `error.type` is the exception class name and the **span status is `ERROR`**, with the exception recorded.
170
+
171
+ This status convention is the same on `chat` and `invoke_agent`: an unhandled exception sets `error.type` to the class name and marks the span `ERROR`; everything else leaves the status unset.
172
+
173
+ ## `execute_guardrail {name}` — the guardrail span
174
+
175
+ `INTERNAL`. One per guardrail execution; a guardrail registered for both phases runs — and emits a span — once in each. The span name suffix is the guardrail's name (e.g. `execute_guardrail profanity_filter`), from `Riffer::Guardrail#name` — the converted class name by default, overridable to relabel the span. This is the one Riffer span with **no `gen_ai.operation.name`**. A guardrail is not a GenAI semantic-convention operation, so the span stays entirely in Riffer's own namespace rather than squat an invented value on the standardized key.
176
+
177
+ | Attribute | Type | Present |
178
+ | ------------------------- | ------ | ----------------------------------------------------------- |
179
+ | `riffer.guardrail.name` | string | Always — the guardrail's name |
180
+ | `riffer.guardrail.phase` | string | Always (`"before"` / `"after"`) |
181
+ | `riffer.guardrail.action` | string | On a returned result (`"pass"` / `"transform"` / `"block"`) |
182
+ | `riffer.tripwire.reason` | string | On a block — the block reason |
183
+ | `error.type` | string | On an unhandled exception |
184
+
185
+ `riffer.guardrail.*` holds the facts true of any execution — name, phase, action. A reason exists only on a block, so it reuses the run-level `riffer.tripwire.reason` key: one query finds the reason on both the per-guardrail span and the enclosing `invoke_agent` summary.
186
+
187
+ A block is a **handled outcome**: `riffer.guardrail.action` is `block` and the **span status stays unset** — the same convention `execute_tool` uses for a returned error response. Only a guardrail that **raises** sets `error.type` to the exception class name and marks the **span status `ERROR`** (with the exception recorded); on a raise no result is produced, so `riffer.guardrail.action` is absent.
188
+
189
+ ## Example trace
190
+
191
+ A `generate` run where the model calls one tool, then answers — with one `before` guardrail and one `after` guardrail, using the OpenAI provider with `gpt-4`. The `after` guardrail runs once per model response, so it appears after each `chat`:
192
+
193
+ ```
194
+ invoke_agent weather-agent INTERNAL
195
+ gen_ai.agent.name = weather-agent
196
+ gen_ai.provider.name = openai
197
+ gen_ai.request.model = gpt-4
198
+ riffer.steps = 2
199
+ gen_ai.usage.input_tokens = 1240
200
+ gen_ai.usage.output_tokens = 86
201
+ riffer.cost = 0.0423
202
+ ├─ execute_guardrail input_filter INTERNAL
203
+ │ riffer.guardrail.name = input_filter
204
+ │ riffer.guardrail.phase = before
205
+ │ riffer.guardrail.action = pass
206
+ ├─ chat gpt-4 CLIENT
207
+ │ gen_ai.request.model = gpt-4
208
+ │ gen_ai.response.finish_reasons = ["tool_calls"]
209
+ │ gen_ai.usage.input_tokens = 612
210
+ │ gen_ai.usage.output_tokens = 48
211
+ │ riffer.cost = 0.0212
212
+ ├─ execute_guardrail output_filter INTERNAL
213
+ │ riffer.guardrail.name = output_filter
214
+ │ riffer.guardrail.phase = after
215
+ │ riffer.guardrail.action = pass
216
+ ├─ execute_tool get_weather INTERNAL
217
+ │ gen_ai.tool.name = get_weather
218
+ │ gen_ai.tool.call.id = tc_42
219
+ ├─ chat gpt-4 CLIENT
220
+ │ gen_ai.request.model = gpt-4
221
+ │ gen_ai.response.finish_reasons = ["stop"]
222
+ │ gen_ai.usage.input_tokens = 628
223
+ │ gen_ai.usage.output_tokens = 38
224
+ │ riffer.cost = 0.0211
225
+ └─ execute_guardrail output_filter INTERNAL
226
+ riffer.guardrail.name = output_filter
227
+ riffer.guardrail.phase = after
228
+ riffer.guardrail.action = pass
229
+ ```
230
+
231
+ ## Token usage and cost
232
+
233
+ `gen_ai.usage.input_tokens` is the **total** prompt tokens for the call, **cache-inclusive**, per the GenAI semantic conventions. `gen_ai.usage.cache_read.input_tokens` and `gen_ai.usage.cache_creation.input_tokens` are **subsets of that total** — the portion served from, or written to, the provider's prompt cache. They are _not_ additional tokens; do not add them on top of `input_tokens`.
234
+
235
+ ```
236
+ input_tokens = 1000
237
+ cache_read.input_tokens = 800 → 800 of the 1000 were cache hits
238
+ (≈ 200 billed as new input)
239
+ ```
240
+
241
+ Riffer normalizes this across providers, so the number may differ from a provider's native API field. Anthropic's raw `input_tokens` _excludes_ the cache buckets — Riffer folds them in. OpenAI's already includes them. Either way the span value means the same thing.
242
+
243
+ **Don't double-count across spans.** Usage on a `chat` span is per-call; usage on the enclosing `invoke_agent` span is the run total already summed across every `chat`. Aggregate one level or the other, never both.
244
+
245
+ ### Cost
246
+
247
+ `riffer.cost` is the modeled cost of one call (on a `chat` span) or a whole run (on the `invoke_agent` span). It lives in Riffer's own namespace because the GenAI semantic conventions define no cost attribute by design — Riffer never squats `gen_ai.*` for it. The attribute appears only when you have configured pricing for the model in use: Riffer ships no price table and never guesses, so an unpriced model simply carries no `riffer.cost`. See [Configuration — Pricing](10_CONFIGURATION.md#pricing) for the rates.
248
+
249
+ The value is **unitless on the wire** — Riffer attaches no currency. It is the sum of the per-token rates you configured, in whatever currency you expressed them, so a `riffer.cost` of `0.0123` means 0.0123 of that unit. The raw float is emitted unrounded; round for display in your backend, not before.
250
+
251
+ **Run cost is all-or-nothing.** The `riffer.cost` on an `invoke_agent` span is the sum of its per-call costs, present only when **every** call in the run was priced. A single unpriced call makes the run-level `riffer.cost` absent — costs sum with nil as absorbing, so Riffer reports no run total rather than a partial one that silently under-reports spend. The priced `chat` spans still each carry their own `riffer.cost`; sum those yourself if a partial is what you want.
252
+
253
+ ## Message content capture
254
+
255
+ The prompt and completion content attributes — `gen_ai.input.messages`, `gen_ai.output.messages`, `gen_ai.system_instructions` on `chat`, and `gen_ai.tool.call.arguments` / `gen_ai.tool.call.result` on `execute_tool` — are **off by default** and gated behind `config.tracing.capture_messages`. Message content routinely carries sensitive data (including PHI); leave capture off unless your trace backend is an appropriate destination for it.
256
+
257
+ When enabled, content is serialized as GenAI-semconv JSON strings. File attachments serialize as metadata-only stubs (media type and name, never bytes). Riffer applies no size limit of its own — cap oversized attributes with the OTEL SDK's attribute length limits. See [Configuration — Tracing](10_CONFIGURATION.md#tracing) for the knob.
258
+
259
+ ## Provider names
260
+
261
+ `gen_ai.provider.name` carries a GenAI-semconv well-known value where one exists: `openai`, `anthropic`, `aws.bedrock`, `azure.ai.openai`, `gcp.gemini`, `openrouter`. A custom provider that doesn't override the value defaults to the snake_cased form of its class name, so enabling tracing never breaks an otherwise-working provider.
262
+
263
+ ## Stability
264
+
265
+ The span and attribute shape is a public, versioned contract, in two tiers:
266
+
267
+ - **`gen_ai.*`** tracks the OpenTelemetry GenAI semantic conventions, pinned to schema version `1.37.0`. That convention is still "Development" status upstream and its attribute names may change; Riffer absorbs such renames deliberately in a release, never silently, with a CHANGELOG entry.
268
+ - **`riffer.*`** is Riffer-owned (`riffer.steps`, `riffer.cost`, `riffer.interrupt.reason`, `riffer.tripwire.*`, `riffer.guardrail.*`, `riffer.finish_reason.raw`) and changes only through a normal version bump and CHANGELOG entry.
269
+
270
+ The semantic-convention schema version is a documented pin rather than a span attribute — the OpenTelemetry Ruby API can't attach a schema URL to a tracer. The runtime version signal is the instrumentation scope: every span carries scope name `riffer` at the gem version that emitted it. Pin the Riffer version your dashboards depend on, and watch the CHANGELOG for tracing entries before upgrading.
271
+
272
+ ## Avoid double instrumentation
273
+
274
+ Riffer instruments the agent loop natively. Running a provider-level GenAI instrumentation gem (for example an OpenTelemetry contrib instrumentation for the underlying Anthropic or OpenAI client) _alongside_ Riffer duplicates the `chat` spans and double-counts token usage. Run one or the other, not both — disable the provider-level instrumentation when Riffer's loop spans are active.
@@ -0,0 +1,153 @@
1
+ # Metrics
2
+
3
+ Riffer can record [OpenTelemetry](https://opentelemetry.io/) metric instruments alongside its [spans](16_TRACING.md), following the [GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/). Metric names, instrument types, units, and attributes are a public, versioned contract you can build dashboards and alerts against. This page is the reference for that contract.
4
+
5
+ As with tracing, Riffer only _records_ instruments, and only through a backend you assign to `config.metrics.backend` — OpenTelemetry is the built-in option you opt into (the host application owns the SDK, metric reader, exporter, and aggregation — the standard OTEL split), but never a default. With no backend assigned, every measurement is a silent no-op and Riffer carries no OpenTelemetry gem dependency.
6
+
7
+ > **OpenTelemetry metrics for Ruby is still pre-1.0.** The metrics API and SDK ship as separate, experimental gems (`opentelemetry-metrics-api`, `opentelemetry-metrics-sdk`) from the stable 1.x traces API. Riffer guards against an incompatible API and falls back to a no-op outside the supported range, but expect the host-side wiring below to evolve with those gems.
8
+
9
+ ## Enabling metrics
10
+
11
+ Riffer records measurements only through a backend you assign to `config.metrics.backend` — it does **not** auto-detect OpenTelemetry. To use OTEL, add the metrics SDK, register a metric reader with an exporter, and assign Riffer's built-in OTEL backend with `Riffer::Metrics::Otel.build`.
12
+
13
+ ```ruby
14
+ # Gemfile
15
+ gem "opentelemetry-metrics-sdk"
16
+ ```
17
+
18
+ ```ruby
19
+ require "opentelemetry-metrics-sdk"
20
+
21
+ OpenTelemetry::SDK.configure do |c|
22
+ c.service_name = "my-agent-host"
23
+ end
24
+
25
+ Riffer.configure do |config|
26
+ config.metrics.backend = Riffer::Metrics::Otel.build
27
+ end
28
+ ```
29
+
30
+ The metrics SDK is **separate** from the traces SDK (`opentelemetry-sdk`); add it explicitly even if you already trace. `Riffer::Metrics::Otel.build` wraps the global `OpenTelemetry.meter_provider` by default; pass `provider:` to wrap a specific one. It returns `nil` — leaving metrics a no-op rather than raising — when the `opentelemetry-metrics-api` gem is absent or outside the supported range (>= 0.2, < 1.0). Any backend implementing the OpenTelemetry Metrics API then ingests Riffer's instruments. For real reader and exporter setup (OTLP, periodic export, Views), see the [OpenTelemetry Ruby docs](https://opentelemetry.io/docs/languages/ruby/).
31
+
32
+ The metrics knobs — the `enabled` kill switch and the `backend` itself — live in [Configuration — Metrics](10_CONFIGURATION.md#metrics). They are **independent** of the tracing knobs (each signal has its own backend): you can run tracing while metrics are off, or the reverse.
33
+
34
+ Instruments are recorded under the instrumentation scope named `riffer`, versioned with the Riffer gem version — the runtime signal for which release produced a measurement; see [Stability](#stability).
35
+
36
+ ## Routing to a non-OpenTelemetry backend
37
+
38
+ OpenTelemetry is one backend, not the only one. A host that already runs another metrics stack — DogStatsD, say — can route Riffer's measurements into it with **no `opentelemetry-*` gem installed** by assigning its own backend to `config.metrics.backend` in place of `Riffer::Metrics::Otel.build`. Whatever you assign is the backend; there is no fallback and no auto-detection — an unset backend is a no-op.
39
+
40
+ ```ruby
41
+ Riffer.configure do |config|
42
+ config.metrics.backend = MyDogStatsdMetricsBackend.new
43
+ end
44
+ ```
45
+
46
+ The backend is duck-typed: any object that responds to `record_histogram(name, value, unit:, description:, attributes:)` works, and the setter validates only that (otherwise it raises `Riffer::ArgumentError`). `Riffer::Metrics::NoOp` is the reference shape. All four instruments are histograms, so the single `record_histogram` method covers the full contract; tell them apart by `name`. Two carry unit `s` — `gen_ai.client.operation.duration` and `riffer.guardrail.duration` — so `name` is the reliable discriminator; `{token}` is `gen_ai.client.token.usage` and `USD` is `riffer.gen_ai.cost`.
47
+
48
+ A custom backend counts as a **live** sink, so the providers still compute the token counts and cost that feed it — the same data that, under OTEL, populates `gen_ai.client.token.usage` and `riffer.gen_ai.cost`. The `enabled` kill switch is honoured ahead of the backend: with `config.metrics.enabled = false`, measurements short-circuit to the no-op without ever reaching a custom backend.
49
+
50
+ ### Bucket boundaries
51
+
52
+ Histogram bucket boundaries are a **host-side** concern. The OpenTelemetry metrics API does not let an instrumenting library attach bucket boundaries at instrument creation, so Riffer does not set them — the SDK's default buckets apply unless you override them. To match the GenAI semantic conventions' recommended boundaries (or your own), register a [View](https://opentelemetry.io/docs/specs/otel/metrics/sdk/#view) on the meter provider that targets the instrument by name and sets explicit bucket boundaries.
53
+
54
+ The convention recommends boundaries scaled to each instrument, so register one View per histogram — the token-count buckets below are for `gen_ai.client.token.usage`; `gen_ai.client.operation.duration` wants its own latency-scaled set, and `riffer.gen_ai.cost` a USD-scaled one.
55
+
56
+ ```ruby
57
+ require "opentelemetry-metrics-sdk"
58
+
59
+ OpenTelemetry::SDK.configure do |c|
60
+ c.service_name = "my-agent-host"
61
+ end
62
+
63
+ # The GenAI semconv's recommended token-count boundaries. Register the View
64
+ # before Riffer records its first measurement.
65
+ OpenTelemetry.meter_provider.add_view(
66
+ "gen_ai.client.token.usage",
67
+ aggregation: OpenTelemetry::SDK::Metrics::Aggregation::ExplicitBucketHistogram.new(
68
+ boundaries: [1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864]
69
+ )
70
+ )
71
+ ```
72
+
73
+ ## Instruments
74
+
75
+ Each instrument is documented here as a row carrying its name, instrument type, unit, and attribute set.
76
+
77
+ ### `gen_ai.client.operation.duration`
78
+
79
+ Histogram, unit `s`. The latency of a single GenAI operation, recorded around the same wrap as the matching [span](16_TRACING.md) on both the success and error paths and timed with a monotonic clock. Recording is independent of tracing — the metric fires even with `config.tracing.enabled = false`. Tell the three operations apart by `gen_ai.operation.name`.
80
+
81
+ | `gen_ai.operation.name` | Recorded around | Attributes |
82
+ | ----------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
83
+ | `chat` | each provider call (`generate_text`/`stream_text`) | `gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.request.model` (when set), `error.type` (on error) |
84
+ | `invoke_agent` | each agent run (`generate`/`stream`) | `gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.request.model`, `gen_ai.agent.name`, `error.type` (on error) |
85
+ | `execute_tool` | each tool call | `gen_ai.operation.name`, `gen_ai.tool.name`, `error.type` (on error) |
86
+
87
+ `error.type` carries the exception class for a raised error; for `execute_tool` it carries the handled error category (e.g. `validation_error`, `timeout_error`) when a tool returns an error result instead of raising — matching the span. `gen_ai.response.model` is not recorded yet; it will land once it is also captured on the chat span.
88
+
89
+ > **Streamed operations are consumption-paced.** A streamed `chat` or `invoke_agent` records its duration when the stream drains, so the value includes the time your consumer takes to iterate the events, not just provider latency. The matching span behaves the same way.
90
+
91
+ > **`gen_ai.tool.name` cardinality.** One time series exists per distinct tool name. With a large or dynamic tool set (for example MCP-discovered tools) that can grow unbounded — drop the attribute with a [View](https://opentelemetry.io/docs/specs/otel/metrics/sdk/#view) if your backend strains.
92
+
93
+ ### `gen_ai.client.token.usage`
94
+
95
+ Histogram, unit `{token}`. Token volume for a single `chat` call, recorded from the normalized token usage after the provider responds. Each call emits **two data points** — one `input`, one `output` — distinguished by `gen_ai.token.type`. Recording is independent of tracing (it fires with `config.tracing.enabled = false`); for a streamed call it fires when the stream drains. `gen_ai.response.model` is not recorded yet, for the same reason as `operation.duration`.
96
+
97
+ | `gen_ai.token.type` | Value | Attributes |
98
+ | ------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
99
+ | `input` | total prompt tokens for the call, **cache-inclusive** | `gen_ai.operation.name` (always `chat`), `gen_ai.provider.name`, `gen_ai.token.type`, `gen_ai.request.model` (when set) |
100
+ | `output` | tokens generated, including reasoning/thinking tokens | same |
101
+
102
+ > **Per-call only.** Token usage is never recorded at the run (`invoke_agent`) level. Metrics pre-aggregate, so emitting both the per-call points and a run total would double-count — sum the per-call points in your backend if you want a run total. This is the metric-side counterpart of the span-level [double-count trap](16_TRACING.md#token-usage-and-cost).
103
+
104
+ > **Cache buckets stay on spans.** The semconv `gen_ai.token.type` defines only `input` and `output`, so the prompt-cache subsets (`cache_read` / `cache_creation`) live on [spans](16_TRACING.md#token-usage-and-cost), not this metric. The `input` value is the cache-inclusive total, matching the span's `gen_ai.usage.input_tokens`.
105
+
106
+ A call that reports no usage records no data points, and a failed call has nothing to count — so this metric carries no `error.type` (the semconv marks it not applicable here, unlike `operation.duration`).
107
+
108
+ ### `riffer.gen_ai.cost`
109
+
110
+ Histogram, unit `USD`. The cost of a single `chat` call, recorded from the [cost](16_TRACING.md#token-usage-and-cost) on the normalized token usage after the provider responds — the same source as the cost span attribute, a different sink. This instrument is Riffer-owned (`riffer.*`, not `gen_ai.*`) so it won't collide if the semantic conventions later define a cost instrument; see [Stability](#stability). Recording is independent of tracing (it fires with `config.tracing.enabled = false`); for a streamed call it fires when the stream drains.
111
+
112
+ | Value | Attributes |
113
+ | ---------------- | -------------------------------------------------------------------------------------------------- |
114
+ | cost of the call | `gen_ai.operation.name` (always `chat`), `gen_ai.provider.name`, `gen_ai.request.model` (when set) |
115
+
116
+ Pricing is **consumer-configured** — no price table ships with the gem (see [Configuration — Pricing](10_CONFIGURATION.md#pricing)). A call whose model has no configured price records **no** data point, so this metric covers only priced calls; `operation.duration` and `token.usage` still record. A priced call that computes to `0.0` does record a zero data point — only an absent price means there is nothing to measure.
117
+
118
+ > **Per-call only.** Cost is never recorded at the run (`invoke_agent`) level, for the same reason as token usage: metrics pre-aggregate, so emitting both per-call points and a run total would double-count. Sum the per-call points in your backend for a run total.
119
+
120
+ ### `riffer.guardrail.duration`
121
+
122
+ Histogram, unit `s`. The latency of a single guardrail execution, recorded around the same wrap as the [`execute_guardrail` span](16_TRACING.md) on both the success and raise paths and timed with a monotonic clock. Guardrails run on the request hot path — before every model turn and after every response — so this is the guardrail counterpart to `gen_ai.client.operation.duration`. Recording is independent of tracing — the metric fires even with `config.tracing.enabled = false`.
123
+
124
+ This instrument is Riffer-owned (`riffer.*`, not `gen_ai.*`) by the same reasoning as its span: a guardrail is not a GenAI semantic-convention operation, so the `execute_guardrail` span deliberately carries no `gen_ai.operation.name` and lives in riffer's own `riffer.guardrail.*` namespace (see [Tracing](16_TRACING.md)). Folding the metric into `gen_ai.client.operation.duration` would contradict that, so the duration is its own riffer-owned histogram instead; see [Stability](#stability).
125
+
126
+ | Value | Attributes |
127
+ | ------------------------------------ | --------------------------------------------------------------------- |
128
+ | duration of the guardrail execution | `riffer.guardrail.name`, `riffer.guardrail.phase`, `error.type` (on raise) |
129
+
130
+ `riffer.guardrail.phase` is `before` or `after`. A pass, transform, or block is a **handled** outcome and records no `error.type` — that attribute carries the exception class only when a guardrail raises, mirroring the span. One time series exists per `riffer.guardrail.name` × `riffer.guardrail.phase`; guardrail names are bounded by the guardrails you configure, so cardinality is safe — unlike the dynamic tool names on `gen_ai.client.operation.duration`.
131
+
132
+ ## Stability
133
+
134
+ The instrument shape is a public, versioned contract, in two tiers — mirroring the [tracing contract](16_TRACING.md#stability):
135
+
136
+ - **`gen_ai.*`** tracks the OpenTelemetry GenAI semantic conventions, pinned to schema version `1.37.0`. That convention is still "Development" status upstream and its names may change; Riffer absorbs such renames deliberately in a release, never silently, with a CHANGELOG entry.
137
+ - **`riffer.*`** is Riffer-owned and changes only through a normal version bump and CHANGELOG entry. Riffer-owned metrics live here so they won't collide if semconv later defines an equivalent.
138
+
139
+ The semantic-convention schema version is a documented pin rather than an instrument attribute — the OpenTelemetry Ruby API can't attach a schema URL to a meter. The runtime version signal is the instrumentation scope: every measurement carries scope name `riffer` at the gem version that recorded it. Pin the Riffer version your dashboards depend on, and watch the CHANGELOG for metrics entries before upgrading.
140
+
141
+ ## Avoid double instrumentation
142
+
143
+ Riffer records its agent loop's metrics natively. Running a provider-level GenAI instrumentation gem (for example an OpenTelemetry contrib instrumentation for the underlying Anthropic or OpenAI client) _alongside_ Riffer records the same `gen_ai.client.*` metrics twice. Because metrics pre-aggregate, that duplication is **silent** — it inflates counts and distorts distributions in your dashboards without any obvious per-event trace to inspect.
144
+
145
+ Record one or the other, not both. Since the metrics kill switch is independent of tracing, the usual resolution is to keep Riffer's spans and turn _Riffer's metrics_ off, letting the provider-level instrumentation own the metrics:
146
+
147
+ ```ruby
148
+ Riffer.configure do |config|
149
+ config.metrics.enabled = false
150
+ end
151
+ ```
152
+
153
+ — or disable the provider-level metric instrumentation and let Riffer own them.
@@ -239,6 +239,50 @@ Riffer::StreamEvents::TokenUsageDone.new(
239
239
  output_tokens: 50
240
240
  )
241
241
  )
242
+
243
+ # Finish reason (emit at end of stream)
244
+ Riffer::StreamEvents::FinishReasonDone.new(
245
+ finish_reason: :stop,
246
+ raw_finish_reason: "done"
247
+ )
248
+ ```
249
+
250
+ ## Token Usage Semantics
251
+
252
+ `Riffer::Providers::TokenUsage` is a normalized contract — map your provider's raw usage into the bucket meanings defined in [Messages — Token Usage Semantics](../08_MESSAGES.md#token-usage-semantics) rather than passing fields through untouched.
253
+
254
+ ## Finish Reasons
255
+
256
+ `Riffer::Providers::FinishReason` is the same kind of normalized contract — map your provider's raw finish/stop value into the vocabulary defined in [Messages — Finish Reasons](../08_MESSAGES.md#finish-reasons) (`:stop`, `:length`, `:tool_calls`, `:content_filter`, `:error`, `:other`), keeping the raw wire value alongside:
257
+
258
+ ```ruby
259
+ def extract_finish_reason(response)
260
+ raw = response.stop_reason
261
+ return nil unless raw
262
+
263
+ Riffer::Providers::FinishReason.new(
264
+ reason: {"done" => :stop, "max_len" => :length}.fetch(raw, :other),
265
+ raw: raw
266
+ )
267
+ end
268
+ ```
269
+
270
+ The hook is optional — the base class defaults to `nil` (no finish reason reported). Map unmapped values to `:other`, never raise on a novel wire value.
271
+
272
+ For streaming, emit a `FinishReasonDone` event near the end of `execute_stream`:
273
+
274
+ ```ruby
275
+ yielder << Riffer::StreamEvents::FinishReasonDone.new(finish_reason: :stop, raw_finish_reason: "done")
276
+ ```
277
+
278
+ ## Trace Provider Name
279
+
280
+ LLM-call and agent-run spans stamp `gen_ai.provider.name` from the `semconv_provider_name` class method. The default is your snake_cased class name; override it when a [GenAI semconv well-known value](https://opentelemetry.io/docs/specs/semconv/gen-ai/) exists for your provider:
281
+
282
+ ```ruby
283
+ def self.semconv_provider_name
284
+ "my_provider"
285
+ end
242
286
  ```
243
287
 
244
288
  ## Error Handling
@@ -28,6 +28,13 @@ class Riffer::Agent::Response
28
28
  # The parsed structured output, if structured output was configured.
29
29
  attr_reader :structured_output #: Hash[Symbol, untyped]?
30
30
 
31
+ # The aggregate token usage across this run's LLM calls, if any was reported.
32
+ attr_reader :token_usage #: Riffer::Providers::TokenUsage?
33
+
34
+ # The number of LLM calls made during this run (0 when a before-guardrail
35
+ # blocks before any call). Distinct from the session's cumulative step count.
36
+ attr_reader :steps #: Integer
37
+
31
38
  # The full message history from the agent conversation.
32
39
  attr_reader :messages #: Array[Riffer::Messages::Base]
33
40
 
@@ -36,8 +43,8 @@ class Riffer::Agent::Response
36
43
  attr_reader :healed_tool_call_ids #: Array[String]
37
44
 
38
45
  #--
39
- #: (String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?messages: Array[Riffer::Messages::Base], ?healed_tool_call_ids: Array[String]) -> void
40
- def initialize(content, tripwire: nil, modifications: [], interrupted: false, interrupt_reason: nil, structured_output: nil, messages: [], healed_tool_call_ids: [])
46
+ #: (String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?messages: Array[Riffer::Messages::Base], ?healed_tool_call_ids: Array[String], ?token_usage: Riffer::Providers::TokenUsage?, ?steps: Integer) -> void
47
+ def initialize(content, tripwire: nil, modifications: [], interrupted: false, interrupt_reason: nil, structured_output: nil, messages: [], healed_tool_call_ids: [], token_usage: nil, steps: 0)
41
48
  @content = content
42
49
  @tripwire = tripwire
43
50
  @modifications = modifications
@@ -46,6 +53,8 @@ class Riffer::Agent::Response
46
53
  @structured_output = structured_output
47
54
  @messages = messages
48
55
  @healed_tool_call_ids = healed_tool_call_ids
56
+ @token_usage = token_usage
57
+ @steps = steps
49
58
  end
50
59
 
51
60
  # Returns true if the response was blocked by a guardrail.