Telemetry
Capture Ratel's search and invoke funnel as local trace events, or export it as OpenTelemetry spans, greenfield or alongside an existing provider.
Ratel emits telemetry on two deliberately separate streams (ADR 0007):
- Local trace events, owned by the Rust core. Structured events (
search,invoke_start,upstream_error, ...) recorded into a sink you configure; this stream feeds the offline inspector, Ratel Local statusline and savings reporting, and future rerankers. Its reliability contract is query-log semantics — best-effort, sampleable, lossy on backpressure: losing an event is acceptable, corrupting a catalog is not. - OpenTelemetry spans. Remote telemetry is OpenTelemetry:
gen_ai.*spans per a pinned semconv baseline plus aratel.*overlay, exported as stock OTLP (http/protobuf+ Bearer auth). This is the stream for Ratel Cloud, Langfuse, or your own collector.
Both are off by default: the local sink is noop until you pass one, and spans are no-ops until an OpenTelemetry provider exists. Pick your path:
- Local visibility, no network → configure a trace sink.
- No OpenTelemetry in your stack yet → call
configure_telemetry/configureTelemetryonce at startup and spans flow to Ratel Cloud. - Already running OpenTelemetry (Langfuse, the Vercel AI SDK, your own collector) → skip
configureTelemetry; your provider already receives the spans. Add the Ratel span processor to dual-export the Ratel cut.
Local trace sinks
Pass trace to the ToolCatalog (or SkillCatalog) constructor and the core records every search, invoke, capability-tool, upstream-MCP, and auth event into the sink.
from ratel_ai import ToolCatalog, TraceSinkConfig
catalog = ToolCatalog(
trace=TraceSinkConfig(kind="jsonl", session_id="session-1", path="/tmp/ratel.jsonl"),
)
# Every catalog.invoke, capability-tool call, and register_mcp_server call now
# appends one JSON line per event to /tmp/ratel.jsonl.TraceSinkConfig is a dataclass: kind is "noop" | "memory" | "jsonl"; session_id is required for memory and jsonl, path for jsonl. Misconfiguration raises ValueError (for example "jsonl sink requires path").
import { ToolCatalog } from "@ratel-ai/sdk";
const catalog = new ToolCatalog({
trace: { kind: "jsonl", sessionId: "session-1", path: "/tmp/ratel.jsonl" },
});
// Every catalog.invoke, capability-tool call, and registerMcpServer call now appends one JSON line per event to /tmp/ratel.jsonl.TraceSinkConfig is a discriminated union: { kind: "noop" }, { kind: "memory"; sessionId }, or { kind: "jsonl"; sessionId; path }. Misconfiguration throws (for example "jsonl sink requires path").
| Kind | Requires | Behavior |
|---|---|---|
noop | — | Drop everything. The default when no trace is passed. |
memory | a session id | Keep events in memory; drain via the catalog. Useful in tests. |
jsonl | a session id and a path | Append one JSON line per event to the file. |
Every recorded event lands wrapped in an envelope: { "v": 1, "ts": <unix epoch ms>, "session_id": "...", "type": "search", ...event fields }, with the event type tagged in snake_case.
- Draining the memory sink.
catalog.drain_trace_events()(Python) /catalog.drainTraceEvents()(TypeScript) returns the buffered events and removes them. It returns[]unless the active sink ismemory; thejsonlandnoopsinks have no drain, read the file instead. - JSONL file behavior. The sink creates missing parent directories, opens the file append-only with mode
0600on Unix, and flushes after every line. Writes are best-effort: a serialization or write failure is swallowed rather than crashing the agent loop. - Origin tagging. Searches the model makes through
search_capabilitiesare taggedorigin: "agent"; directcatalog.search(query, k)calls default to"direct", overridable with a third argument.
Event types
The event set is defined once, in the Rust core, so every SDK emits the same shapes. The full type vocabulary:
type | Recorded on | Emitted by |
|---|---|---|
search | every ToolCatalog.search, including the one inside search_capabilities | SDK |
index_churn | a tool added to or removed from the index | SDK |
skill_search | every SkillCatalog.search, including the one inside search_capabilities | SDK |
skill_churn | a skill added to the index | SDK |
skill_invoke | every SkillCatalog.invoke, including via get_skill_content | SDK |
invoke_start, invoke_end, invoke_error | around every ToolCatalog.invoke: start, then end on success or error on failure (the error is rethrown after recording) | SDK |
gateway_search | every search_capabilities call, recorded at the capability-tool layer | SDK |
gateway_invoke, gateway_error | an invoke_tool call succeeding or failing (unknown id, unauthorized upstream, executor error); gateway_error also covers get_skill_content with an unknown skill id | SDK |
upstream_register | every registerMcpServer / register_mcp_server | SDK |
upstream_invoke, upstream_error | a proxied upstream-MCP tool call succeeding or failing | SDK |
auth_refresh | an upstream token refresh attempt | Ratel Local |
auth_needs | an upstream MCP server needing (re)authorization | Ratel Local |
auth_flow_start, auth_flow_end | the OAuth flow for an upstream starting and finishing | Ratel Local |
embedder_load | once, on the first (cold) load of the embedding model | SDK |
A single agent call often records several of these: search_capabilities produces both a core-level search (or skill_search) and a gateway_search, and invoke_tool produces invoke_start / invoke_end (or invoke_error) plus gateway_invoke / gateway_error, with upstream_invoke / upstream_error on top for proxied MCP tools. The four auth_* events come only from Ratel Local OAuth upstreams, which embeds the SDK and records into the same stream; the SDKs never emit them.
The OpenTelemetry span vocabulary
Independently of the local sink, the SDK emits OpenTelemetry spans around the same funnel: gen_ai.* attributes pinned to semconv v1.42.0, plus the Ratel-owned ratel.* overlay. The local event and the span are two independent channels around the same call.
| Span | Emitted around | Attributes |
|---|---|---|
ratel.search | every ToolCatalog.search / SkillCatalog.search, including the one inside search_capabilities | ratel.search.target (tool | skill), ratel.origin, ratel.search.top_k, ratel.search.hit_count; ratel.search.query only when content capture selects a span mode |
execute_tool <tool id> | every ToolCatalog.invoke, including via invoke_tool | gen_ai.operation.name = "execute_tool", gen_ai.tool.name, ratel.tool.args_size_bytes; gated: gen_ai.tool.call.arguments, gen_ai.tool.call.result |
ratel.skill.load | SkillCatalog.invoke, including via get_skill_content | ratel.skill.id |
ratel.upstream.register | registerMcpServer / register_mcp_server (connect + list + ingest) | ratel.upstream.server, ratel.upstream.transport, ratel.upstream.tool_count |
ratel.auth.flow | invoke_tool hitting an unauthorized (401) upstream | ratel.auth.outcome = "needs_auth", plus ratel.upstream.server when known |
Tool invocations deliberately reuse execute_tool, the standard gen_ai.operation.name value, instead of a bespoke ratel.invoke span, so a generic OTel backend already understands them. Errors use the standard span status ERROR plus the exception event, not a bespoke attribute. The SDK does not emit the LLM-call spans (chat <model>): those come from your LLM instrumentation, and Ratel's spans join the same trace next to them.
No-op until a provider
Instrumentation is free until you opt in, in both languages:
- TypeScript: the SDK's only OpenTelemetry dependency is
@opentelemetry/api, never the OTel SDK. Until a provider is registered, every span is a no-opNonRecordingSpan, and the local trace stream is untouched. - Python: the base
ratel-aiinstall has zero dependencies. Ifopentelemetryorratel_ai_telemetryis missing, every telemetry helper is a straight pass-through, zero overhead, no spans. With them installed, spans go to whatever provider is registered.
Greenfield: ship spans to Ratel Cloud
No OpenTelemetry in your stack yet? One call at startup registers a global tracer provider with a batch OTLP exporter pointed at Ratel Cloud.
The exporter ships behind the [otlp] extra (without it, configure_telemetry raises ModuleNotFoundError with this install hint):
pip install 'ratel-ai[otlp]'import os
from ratel_ai import configure_telemetry
handle = configure_telemetry(
endpoint=os.environ.get("RATEL_URL", "https://cloud.ratel.sh/v1/traces"),
api_key=os.environ["RATEL_API_KEY"],
)
# ... run your agent: every ratel.* / execute_tool span now exports ...
handle.shutdown() # flush the exporter on exit (handle.force_flush() mid-run)configure_telemetry(*, api_key=None, endpoint=None, headers=None, service_name=None, capture_content=None, include_span_and_events=None) returns a per-call shutdown handle (handle.shutdown() / handle.force_flush()).
The exporter is the optional peer @ratel-ai/telemetry-otlp (without it, configureTelemetry throws with this install hint):
npm install @ratel-ai/telemetry-otlpimport { configureTelemetry } from "@ratel-ai/sdk";
const handle = await configureTelemetry({
endpoint: process.env.RATEL_URL ?? "https://cloud.ratel.sh/v1/traces",
apiKey: process.env.RATEL_API_KEY,
});
// ... run your agent: every ratel.* / execute_tool span now exports ...
await handle.shutdown(); // flushes and stops the exporterconfigureTelemetry({ serviceName?, apiKey?, endpoint?, headers?, captureContent?, includeSpanAndEvents? }) resolves to a TelemetryHandle with a shutdown() method.
Endpoint and auth resolve identically in both languages:
- The
endpointoption wins; otherwise theRATEL_URLenv var. Either way it is the full OTLP traces URL including/v1/traces, for examplehttps://cloud.ratel.sh/v1/traces. With neither set, the call fails before anything registers. - The API key is sent as
Authorization: Bearer <key>on top of any extraheaders. When omitted it falls back to theRATEL_API_KEYenv var (unless you already set anAuthorizationheader explicitly). service_name/serviceNamedefaults to"ratel".
Runnable examples live in the repo: examples/telemetry-ts and examples/telemetry-python emit a sample ratel.search + execute_tool trace to a console exporter, no collector or API key needed, and switch to a real OTLP export when RATEL_URL is set.
configureTelemetry owns the global provider. If one is already registered it fails loudly, pointing at the span processor, rather than silently no-op'ing. That error means you are on the wrong path: dual-export instead.
Already on OpenTelemetry: dual-export
OpenTelemetry's coexistence model is one provider, many span processors. If Langfuse, the Vercel AI SDK, or your own collector already owns the provider, the SDK's spans are already flowing to it, so skip configureTelemetry and add the Ratel span processor to send a copy to Ratel Cloud:
import os
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from ratel_ai_telemetry.otlp import ratel_span_processor
provider = trace.get_tracer_provider() # the provider your existing setup registered
if not isinstance(provider, TracerProvider):
raise RuntimeError("register your OpenTelemetry SDK provider before adding processors")
provider.add_span_processor(
ratel_span_processor(
endpoint=os.environ.get("RATEL_URL", "https://cloud.ratel.sh/v1/traces"),
api_key=os.environ["RATEL_API_KEY"],
)
)ratel_span_processor(*, api_key=None, endpoint=None, headers=None, span_filter=None, enabled=True); ratel_span_exporter(...) is the bare OTLP exporter if you wire your own processor.
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { LangfuseSpanProcessor } from "@langfuse/otel";
import { ratelSpanProcessor } from "@ratel-ai/telemetry-otlp";
const provider = new NodeTracerProvider({
spanProcessors: [
new LangfuseSpanProcessor(), // Langfuse keeps every span
ratelSpanProcessor({ // Ratel takes gen_ai.*/ratel.* only
endpoint: process.env.RATEL_URL ?? "https://cloud.ratel.sh/v1/traces",
apiKey: process.env.RATEL_API_KEY,
}),
],
});
provider.register();ratelSpanProcessor(opts) accepts the endpoint/auth options (endpoint, apiKey, headers) plus spanFilter and enabled; ratelTraceExporter(opts) is the bare OTLP exporter if you wire your own processor.
The processor batches spans to the Ratel OTLP exporter and forwards only those passing its filter:
- The default filter forwards a span if its name starts with
ratel.or any attribute key starts withgen_ai.orratel., so the framework'sai.*wrapper noise stays out. - Pass
spanFilter: () => true(TypeScript) /span_filter=lambda _s: True(Python) to forward everything. Do that, or tail-sample, when full-trace fidelity matters — per-span filtering can orphan the AI SDK'sai.*wrapper span from itsgen_ai.*child. - The exporter carries no resource; your provider keeps ownership of
service.name.
Content capture
Message and tool content never leaves the process by default. Set the mode in code — configure_telemetry(capture_content=...) / configureTelemetry({ captureContent }) — or through the ecosystem instrumentation flag OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, the fallback when nothing is set in code; parsing is trimmed and case-insensitive.
| Value | Mode |
|---|---|
unset / empty / NO_CONTENT | no content (the default) |
SPAN_ONLY | content on span attributes |
EVENT_ONLY | content on span events only, never span attributes |
SPAN_AND_EVENT | both |
TRUE / 1 (legacy boolean) | SPAN_AND_EVENT |
anything else (including FALSE, 0) | no content |
The gated payloads in the SDK are ratel.search.query on ratel.search and gen_ai.tool.call.arguments / gen_ai.tool.call.result on execute_tool. They appear only when the mode is a span mode (SPAN_ONLY or SPAN_AND_EVENT). EVENT_ONLY does not put content on spans, and since the 0.4.0 SDKs emit no content-bearing span events, it captures nothing from the SDK today: the gen_ai.client.inference.operation.details event is the convention for message content on your LLM instrumentation's spans, not something the Ratel SDK emits.
Next steps
Telemetry (TypeScript)
Set up this trace stream in a Node agent.
Telemetry (Python)
The same setup, idiomatic Python.
Framework integrations
Wire the capability tools into the agent framework you already run. TypeScript shown; the Python twin mirrors it.
Ratel Local statusline
Operate the local JSONL stream and Claude Code statusline.