riffer 0.32.0 → 0.33.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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +34 -0
- data/README.md +13 -11
- data/docs/01_OVERVIEW.md +2 -0
- data/docs/04_AGENT_LIFECYCLE.md +15 -13
- data/docs/08_MESSAGES.md +39 -5
- data/docs/09_STREAM_EVENTS.md +14 -0
- data/docs/10_CONFIGURATION.md +73 -4
- data/docs/13_SKILLS.md +66 -4
- data/docs/14_MCP.md +2 -1
- data/docs/16_TRACING.md +250 -0
- data/docs/17_METRICS.md +123 -0
- data/docs/providers/07_CUSTOM_PROVIDERS.md +44 -0
- data/lib/riffer/agent/response.rb +11 -2
- data/lib/riffer/agent/run.rb +136 -35
- data/lib/riffer/agent.rb +5 -5
- data/lib/riffer/config.rb +231 -15
- data/lib/riffer/guardrail.rb +8 -0
- data/lib/riffer/guardrails/runner.rb +33 -0
- data/lib/riffer/helpers/boolean.rb +22 -0
- data/lib/riffer/mcp/authenticated_tool.rb +14 -20
- data/lib/riffer/mcp/registration.rb +4 -4
- data/lib/riffer/mcp/tool.rb +23 -0
- data/lib/riffer/mcp/tool_factory.rb +14 -22
- data/lib/riffer/messages/assistant.rb +15 -3
- data/lib/riffer/messages/base.rb +2 -1
- data/lib/riffer/metrics/instruments.rb +25 -0
- data/lib/riffer/metrics/null.rb +14 -0
- data/lib/riffer/metrics/otel.rb +79 -0
- data/lib/riffer/metrics.rb +93 -0
- data/lib/riffer/providers/amazon_bedrock.rb +57 -21
- data/lib/riffer/providers/anthropic.rb +59 -24
- data/lib/riffer/providers/azure_open_ai.rb +7 -0
- data/lib/riffer/providers/base.rb +247 -15
- data/lib/riffer/providers/finish_reason.rb +27 -0
- data/lib/riffer/providers/gemini.rb +59 -11
- data/lib/riffer/providers/mock.rb +30 -9
- data/lib/riffer/providers/open_ai.rb +78 -24
- data/lib/riffer/providers/open_router.rb +56 -16
- data/lib/riffer/providers/repository.rb +9 -0
- data/lib/riffer/providers/token_usage.rb +27 -11
- data/lib/riffer/skills/activate_tool.rb +12 -2
- data/lib/riffer/skills/adapter.rb +15 -0
- data/lib/riffer/skills/context.rb +78 -11
- data/lib/riffer/skills/frontmatter.rb +13 -5
- data/lib/riffer/skills/markdown_adapter.rb +1 -1
- data/lib/riffer/skills/xml_adapter.rb +1 -1
- data/lib/riffer/stream_events/finish_reason_done.rb +34 -0
- data/lib/riffer/tools/runtime.rb +99 -3
- data/lib/riffer/tracing/capture.rb +92 -0
- data/lib/riffer/tracing/null.rb +61 -0
- data/lib/riffer/tracing/otel.rb +131 -0
- data/lib/riffer/tracing/stream_recorder.rb +51 -0
- data/lib/riffer/tracing.rb +78 -0
- data/lib/riffer/version.rb +1 -1
- data/sig/_private/opentelemetry.rbs +22 -0
- data/sig/generated/riffer/agent/response.rbs +9 -2
- data/sig/generated/riffer/agent/run.rbs +28 -8
- data/sig/generated/riffer/config.rbs +162 -16
- data/sig/generated/riffer/guardrail.rbs +6 -0
- data/sig/generated/riffer/guardrails/runner.rbs +14 -0
- data/sig/generated/riffer/helpers/boolean.rbs +11 -0
- data/sig/generated/riffer/mcp/authenticated_tool.rbs +6 -8
- data/sig/generated/riffer/mcp/registration.rbs +4 -4
- data/sig/generated/riffer/mcp/tool.rbs +19 -0
- data/sig/generated/riffer/mcp/tool_factory.rbs +8 -7
- data/sig/generated/riffer/messages/assistant.rbs +10 -4
- data/sig/generated/riffer/metrics/instruments.rbs +13 -0
- data/sig/generated/riffer/metrics/null.rbs +10 -0
- data/sig/generated/riffer/metrics/otel.rbs +47 -0
- data/sig/generated/riffer/metrics.rbs +71 -0
- data/sig/generated/riffer/providers/amazon_bedrock.rbs +35 -14
- data/sig/generated/riffer/providers/anthropic.rbs +41 -20
- data/sig/generated/riffer/providers/azure_open_ai.rbs +5 -0
- data/sig/generated/riffer/providers/base.rbs +78 -2
- data/sig/generated/riffer/providers/finish_reason.rbs +19 -0
- data/sig/generated/riffer/providers/gemini.rbs +25 -2
- data/sig/generated/riffer/providers/mock.rbs +16 -5
- data/sig/generated/riffer/providers/open_ai.rbs +44 -22
- data/sig/generated/riffer/providers/open_router.rbs +31 -12
- data/sig/generated/riffer/providers/repository.rbs +7 -0
- data/sig/generated/riffer/providers/token_usage.rbs +20 -10
- data/sig/generated/riffer/skills/activate_tool.rbs +7 -1
- data/sig/generated/riffer/skills/adapter.rbs +10 -0
- data/sig/generated/riffer/skills/context.rbs +52 -4
- data/sig/generated/riffer/skills/frontmatter.rbs +10 -3
- data/sig/generated/riffer/stream_events/finish_reason_done.rbs +21 -0
- data/sig/generated/riffer/tools/runtime.rbs +35 -0
- data/sig/generated/riffer/tracing/capture.rbs +46 -0
- data/sig/generated/riffer/tracing/null.rbs +46 -0
- data/sig/generated/riffer/tracing/otel.rbs +83 -0
- data/sig/generated/riffer/tracing/stream_recorder.rbs +31 -0
- data/sig/generated/riffer/tracing.rbs +52 -0
- data/sig/manual/riffer/helpers/boolean.rbs +5 -0
- data/sig/manual/riffer/metrics/null.rbs +5 -0
- data/sig/manual/riffer/metrics.rbs +5 -0
- data/sig/manual/riffer/providers.rbs +9 -0
- data/sig/manual/riffer/tracing/capture.rbs +5 -0
- data/sig/manual/riffer/tracing/null.rbs +5 -0
- data/sig/manual/riffer/tracing.rbs +5 -0
- metadata +40 -4
data/docs/16_TRACING.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
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. The host application owns the OpenTelemetry SDK, the exporter, sampling, and service naming — the standard OTEL split. Without a host SDK, every span is a silent no-op and Riffer carries no OpenTelemetry gem dependency.
|
|
6
|
+
|
|
7
|
+
## Enabling tracing
|
|
8
|
+
|
|
9
|
+
Add the OpenTelemetry SDK to your host application and configure an exporter. Riffer detects the API at runtime and starts emitting through whatever provider the SDK configures — there is nothing to switch on in Riffer beyond the SDK being present.
|
|
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
|
+
|
|
24
|
+
To see Riffer's spans on stdout while developing locally, wire in the console exporter:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
require "opentelemetry/sdk"
|
|
28
|
+
|
|
29
|
+
OpenTelemetry::SDK.configure do |c|
|
|
30
|
+
c.service_name = "my-agent-host"
|
|
31
|
+
c.add_span_processor(
|
|
32
|
+
OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(
|
|
33
|
+
OpenTelemetry::SDK::Trace::Export::ConsoleSpanExporter.new
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Any backend that implements the OpenTelemetry Traces API ingests Riffer's spans with no second pipeline — including the `datadog` gem (`require "datadog/opentelemetry"`), which routes them through an existing tracer so they nest inside the host's live trace. For real exporter and collector setup (OTLP, sampling, resource attributes), see the [OpenTelemetry Ruby docs](https://opentelemetry.io/docs/languages/ruby/).
|
|
40
|
+
|
|
41
|
+
The three tracing knobs — the `enabled` kill switch, opt-in message-content capture, and an explicit tracer provider for tests — live in [Configuration — Tracing](10_CONFIGURATION.md#tracing).
|
|
42
|
+
|
|
43
|
+
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).
|
|
44
|
+
|
|
45
|
+
## Spans
|
|
46
|
+
|
|
47
|
+
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:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
invoke_agent {agent} INTERNAL
|
|
51
|
+
├─ execute_guardrail {name} INTERNAL (one per before-phase guardrail)
|
|
52
|
+
├─ chat {model} CLIENT (one per LLM call)
|
|
53
|
+
├─ execute_tool {tool} INTERNAL (one per tool call)
|
|
54
|
+
│ └─ (host spans nest here via around_tool_call / tool internals)
|
|
55
|
+
├─ execute_guardrail {name} INTERNAL (one per after-phase guardrail, after each response)
|
|
56
|
+
├─ chat {model}
|
|
57
|
+
└─ …
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
62
|
+
### Reading the attribute tables
|
|
63
|
+
|
|
64
|
+
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:
|
|
65
|
+
|
|
66
|
+
- **`Always`** — emitted on every span of that type.
|
|
67
|
+
- **`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.
|
|
68
|
+
- **`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.
|
|
69
|
+
|
|
70
|
+
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.
|
|
71
|
+
|
|
72
|
+
## `invoke_agent {agent}` — the run span
|
|
73
|
+
|
|
74
|
+
`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`).
|
|
75
|
+
|
|
76
|
+
| Attribute | Type | Present |
|
|
77
|
+
| ------------------------------------------ | ------ | ---------------------------------------------------- |
|
|
78
|
+
| `gen_ai.operation.name` | string | Always (`"invoke_agent"`) |
|
|
79
|
+
| `gen_ai.agent.name` | string | Always — the agent's identifier |
|
|
80
|
+
| `gen_ai.provider.name` | string | Always — see [provider names](#provider-names) |
|
|
81
|
+
| `gen_ai.request.model` | string | Always — the agent's configured model |
|
|
82
|
+
| `riffer.steps` | int | Always — number of LLM calls in the run |
|
|
83
|
+
| `gen_ai.usage.input_tokens` | int | When the run made an LLM call that reported usage |
|
|
84
|
+
| `gen_ai.usage.output_tokens` | int | When the run made an LLM call that reported usage |
|
|
85
|
+
| `gen_ai.usage.cache_read.input_tokens` | int | When the provider reported cache reads |
|
|
86
|
+
| `gen_ai.usage.cache_creation.input_tokens` | int | When the provider reported cache writes |
|
|
87
|
+
| `riffer.cost` | float | When every call in the run was priced |
|
|
88
|
+
| `riffer.interrupt.reason` | string | On interrupt (e.g. approval needed, max steps) |
|
|
89
|
+
| `riffer.tripwire.guardrail` | string | On a guardrail tripwire, when the guardrail is named |
|
|
90
|
+
| `riffer.tripwire.reason` | string | On a guardrail tripwire |
|
|
91
|
+
| `riffer.tripwire.phase` | string | On a guardrail tripwire (`"before"` / `"after"`) |
|
|
92
|
+
| `error.type` | string | On an unhandled exception |
|
|
93
|
+
|
|
94
|
+
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.
|
|
95
|
+
|
|
96
|
+
Usage on this span is the run total, aggregated across every step. See [Token usage](#token-usage) for the trap this creates.
|
|
97
|
+
|
|
98
|
+
## `chat {model}` — the LLM call span
|
|
99
|
+
|
|
100
|
+
`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.
|
|
101
|
+
|
|
102
|
+
| Attribute | Type | Present |
|
|
103
|
+
| ------------------------------------------ | -------- | ----------------------------------------------------------------------------- |
|
|
104
|
+
| `gen_ai.operation.name` | string | Always (`"chat"`) |
|
|
105
|
+
| `gen_ai.provider.name` | string | Always — see [provider names](#provider-names) |
|
|
106
|
+
| `gen_ai.request.model` | string | When a model is set |
|
|
107
|
+
| `gen_ai.request.temperature` | float | When the caller set it |
|
|
108
|
+
| `gen_ai.request.max_tokens` | int | When the caller set `max_tokens` or `max_output_tokens` |
|
|
109
|
+
| `gen_ai.request.top_p` | float | When the caller set it |
|
|
110
|
+
| `gen_ai.request.top_k` | int | When the caller set it |
|
|
111
|
+
| `gen_ai.request.frequency_penalty` | float | When the caller set it |
|
|
112
|
+
| `gen_ai.request.presence_penalty` | float | When the caller set it |
|
|
113
|
+
| `gen_ai.request.seed` | int | When the caller set it |
|
|
114
|
+
| `gen_ai.request.stop_sequences` | string[] | When the caller set it |
|
|
115
|
+
| `gen_ai.usage.input_tokens` | int | When the provider reported usage |
|
|
116
|
+
| `gen_ai.usage.output_tokens` | int | When the provider reported usage |
|
|
117
|
+
| `gen_ai.usage.cache_read.input_tokens` | int | When the provider reported cache reads |
|
|
118
|
+
| `gen_ai.usage.cache_creation.input_tokens` | int | When the provider reported cache writes |
|
|
119
|
+
| `riffer.cost` | float | When the call's model was priced |
|
|
120
|
+
| `gen_ai.response.finish_reasons` | string[] | When the provider reported a finish reason |
|
|
121
|
+
| `riffer.finish_reason.raw` | string | When the raw value differs from the normalized one |
|
|
122
|
+
| `gen_ai.input.messages` | string | When `capture_messages` is on (JSON; see [capture](#message-content-capture)) |
|
|
123
|
+
| `gen_ai.system_instructions` | string | When `capture_messages` is on and a system prompt exists |
|
|
124
|
+
| `gen_ai.output.messages` | string | When `capture_messages` is on (JSON) |
|
|
125
|
+
| `error.type` | string | On an unhandled exception |
|
|
126
|
+
|
|
127
|
+
`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`.
|
|
128
|
+
|
|
129
|
+
## `execute_tool {tool}` — the tool call span
|
|
130
|
+
|
|
131
|
+
`INTERNAL`. One per tool call dispatched by the runtime. The span name suffix is the tool's name (e.g. `execute_tool get_weather`).
|
|
132
|
+
|
|
133
|
+
| Attribute | Type | Present |
|
|
134
|
+
| ---------------------------- | ------ | ----------------------------------------------------------------------- |
|
|
135
|
+
| `gen_ai.operation.name` | string | Always (`"execute_tool"`) |
|
|
136
|
+
| `gen_ai.tool.name` | string | Always |
|
|
137
|
+
| `gen_ai.tool.call.id` | string | Always — the originating tool-call id |
|
|
138
|
+
| `error.type` | string | On a tool error (see below) |
|
|
139
|
+
| `gen_ai.tool.call.arguments` | string | When `capture_messages` is on (see [capture](#message-content-capture)) |
|
|
140
|
+
| `gen_ai.tool.call.result` | string | When `capture_messages` is on |
|
|
141
|
+
|
|
142
|
+
A tool failure comes in two shapes, distinguished by span status:
|
|
143
|
+
|
|
144
|
+
- **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:)`.
|
|
145
|
+
- **Unhandled exception** — the dispatch raised. `error.type` is the exception class name and the **span status is `ERROR`**, with the exception recorded.
|
|
146
|
+
|
|
147
|
+
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.
|
|
148
|
+
|
|
149
|
+
## `execute_guardrail {name}` — the guardrail span
|
|
150
|
+
|
|
151
|
+
`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.
|
|
152
|
+
|
|
153
|
+
| Attribute | Type | Present |
|
|
154
|
+
| ------------------------- | ------ | ----------------------------------------------------------- |
|
|
155
|
+
| `riffer.guardrail.name` | string | Always — the guardrail's name |
|
|
156
|
+
| `riffer.guardrail.phase` | string | Always (`"before"` / `"after"`) |
|
|
157
|
+
| `riffer.guardrail.action` | string | On a returned result (`"pass"` / `"transform"` / `"block"`) |
|
|
158
|
+
| `riffer.tripwire.reason` | string | On a block — the block reason |
|
|
159
|
+
| `error.type` | string | On an unhandled exception |
|
|
160
|
+
|
|
161
|
+
`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.
|
|
162
|
+
|
|
163
|
+
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.
|
|
164
|
+
|
|
165
|
+
## Example trace
|
|
166
|
+
|
|
167
|
+
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`:
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
invoke_agent weather-agent INTERNAL
|
|
171
|
+
gen_ai.agent.name = weather-agent
|
|
172
|
+
gen_ai.provider.name = openai
|
|
173
|
+
gen_ai.request.model = gpt-4
|
|
174
|
+
riffer.steps = 2
|
|
175
|
+
gen_ai.usage.input_tokens = 1240
|
|
176
|
+
gen_ai.usage.output_tokens = 86
|
|
177
|
+
riffer.cost = 0.0423
|
|
178
|
+
├─ execute_guardrail input_filter INTERNAL
|
|
179
|
+
│ riffer.guardrail.name = input_filter
|
|
180
|
+
│ riffer.guardrail.phase = before
|
|
181
|
+
│ riffer.guardrail.action = pass
|
|
182
|
+
├─ chat gpt-4 CLIENT
|
|
183
|
+
│ gen_ai.request.model = gpt-4
|
|
184
|
+
│ gen_ai.response.finish_reasons = ["tool_calls"]
|
|
185
|
+
│ gen_ai.usage.input_tokens = 612
|
|
186
|
+
│ gen_ai.usage.output_tokens = 48
|
|
187
|
+
│ riffer.cost = 0.0212
|
|
188
|
+
├─ execute_guardrail output_filter INTERNAL
|
|
189
|
+
│ riffer.guardrail.name = output_filter
|
|
190
|
+
│ riffer.guardrail.phase = after
|
|
191
|
+
│ riffer.guardrail.action = pass
|
|
192
|
+
├─ execute_tool get_weather INTERNAL
|
|
193
|
+
│ gen_ai.tool.name = get_weather
|
|
194
|
+
│ gen_ai.tool.call.id = tc_42
|
|
195
|
+
├─ chat gpt-4 CLIENT
|
|
196
|
+
│ gen_ai.request.model = gpt-4
|
|
197
|
+
│ gen_ai.response.finish_reasons = ["stop"]
|
|
198
|
+
│ gen_ai.usage.input_tokens = 628
|
|
199
|
+
│ gen_ai.usage.output_tokens = 38
|
|
200
|
+
│ riffer.cost = 0.0211
|
|
201
|
+
└─ execute_guardrail output_filter INTERNAL
|
|
202
|
+
riffer.guardrail.name = output_filter
|
|
203
|
+
riffer.guardrail.phase = after
|
|
204
|
+
riffer.guardrail.action = pass
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Token usage and cost
|
|
208
|
+
|
|
209
|
+
`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`.
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
input_tokens = 1000
|
|
213
|
+
cache_read.input_tokens = 800 → 800 of the 1000 were cache hits
|
|
214
|
+
(≈ 200 billed as new input)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
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.
|
|
218
|
+
|
|
219
|
+
**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.
|
|
220
|
+
|
|
221
|
+
### Cost
|
|
222
|
+
|
|
223
|
+
`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.
|
|
224
|
+
|
|
225
|
+
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.
|
|
226
|
+
|
|
227
|
+
**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.
|
|
228
|
+
|
|
229
|
+
## Message content capture
|
|
230
|
+
|
|
231
|
+
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.
|
|
232
|
+
|
|
233
|
+
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.
|
|
234
|
+
|
|
235
|
+
## Provider names
|
|
236
|
+
|
|
237
|
+
`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.
|
|
238
|
+
|
|
239
|
+
## Stability
|
|
240
|
+
|
|
241
|
+
The span and attribute shape is a public, versioned contract, in two tiers:
|
|
242
|
+
|
|
243
|
+
- **`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.
|
|
244
|
+
- **`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.
|
|
245
|
+
|
|
246
|
+
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.
|
|
247
|
+
|
|
248
|
+
## Avoid double instrumentation
|
|
249
|
+
|
|
250
|
+
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.
|
data/docs/17_METRICS.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
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. The host application owns the OpenTelemetry SDK, the metric reader, the exporter, and the aggregation — the standard OTEL split. Without a host SDK, 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
|
+
Add the OpenTelemetry metrics SDK to your host application and register a metric reader with an exporter. Riffer detects the metrics API at runtime and starts recording through whatever meter provider the SDK configures.
|
|
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
|
+
|
|
26
|
+
The metrics SDK is **separate** from the traces SDK (`opentelemetry-sdk`); add it explicitly even if you already trace. Any backend implementing the OpenTelemetry Metrics API 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/).
|
|
27
|
+
|
|
28
|
+
The two metrics knobs — the `enabled` kill switch and an explicit meter provider for tests — live in [Configuration — Metrics](10_CONFIGURATION.md#metrics). They are **independent** of the tracing knobs: you can run tracing while metrics are off, or the reverse.
|
|
29
|
+
|
|
30
|
+
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).
|
|
31
|
+
|
|
32
|
+
### Bucket boundaries
|
|
33
|
+
|
|
34
|
+
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.
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
require "opentelemetry-metrics-sdk"
|
|
40
|
+
|
|
41
|
+
OpenTelemetry::SDK.configure do |c|
|
|
42
|
+
c.service_name = "my-agent-host"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The GenAI semconv's recommended token-count boundaries. Register the View
|
|
46
|
+
# before Riffer records its first measurement.
|
|
47
|
+
OpenTelemetry.meter_provider.add_view(
|
|
48
|
+
"gen_ai.client.token.usage",
|
|
49
|
+
aggregation: OpenTelemetry::SDK::Metrics::Aggregation::ExplicitBucketHistogram.new(
|
|
50
|
+
boundaries: [1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864]
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Instruments
|
|
56
|
+
|
|
57
|
+
Each instrument is documented here as a row carrying its name, instrument type, unit, and attribute set.
|
|
58
|
+
|
|
59
|
+
### `gen_ai.client.operation.duration`
|
|
60
|
+
|
|
61
|
+
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`.
|
|
62
|
+
|
|
63
|
+
| `gen_ai.operation.name` | Recorded around | Attributes |
|
|
64
|
+
| ----------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
65
|
+
| `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) |
|
|
66
|
+
| `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) |
|
|
67
|
+
| `execute_tool` | each tool call | `gen_ai.operation.name`, `gen_ai.tool.name`, `error.type` (on error) |
|
|
68
|
+
|
|
69
|
+
`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.
|
|
70
|
+
|
|
71
|
+
> **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.
|
|
72
|
+
|
|
73
|
+
> **`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.
|
|
74
|
+
|
|
75
|
+
### `gen_ai.client.token.usage`
|
|
76
|
+
|
|
77
|
+
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`.
|
|
78
|
+
|
|
79
|
+
| `gen_ai.token.type` | Value | Attributes |
|
|
80
|
+
| ------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
81
|
+
| `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) |
|
|
82
|
+
| `output` | tokens generated, including reasoning/thinking tokens | same |
|
|
83
|
+
|
|
84
|
+
> **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).
|
|
85
|
+
|
|
86
|
+
> **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`.
|
|
87
|
+
|
|
88
|
+
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`).
|
|
89
|
+
|
|
90
|
+
### `riffer.gen_ai.cost`
|
|
91
|
+
|
|
92
|
+
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.
|
|
93
|
+
|
|
94
|
+
| Value | Attributes |
|
|
95
|
+
| ---------------- | -------------------------------------------------------------------------------------------------- |
|
|
96
|
+
| cost of the call | `gen_ai.operation.name` (always `chat`), `gen_ai.provider.name`, `gen_ai.request.model` (when set) |
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
> **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.
|
|
101
|
+
|
|
102
|
+
## Stability
|
|
103
|
+
|
|
104
|
+
The instrument shape is a public, versioned contract, in two tiers — mirroring the [tracing contract](16_TRACING.md#stability):
|
|
105
|
+
|
|
106
|
+
- **`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.
|
|
107
|
+
- **`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.
|
|
108
|
+
|
|
109
|
+
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.
|
|
110
|
+
|
|
111
|
+
## Avoid double instrumentation
|
|
112
|
+
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
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:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
Riffer.configure do |config|
|
|
119
|
+
config.metrics.enabled = false
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
— 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.
|