operonx.telemetry¶
V3 tracing surface — automatic op-level recording, Consumer subclasses
convert the recorded WorkflowTrace into whatever target format they
want. See Operon(pipeline, trace=…) for how to wire consumers.
Consumer base¶
consumer
¶
Consumer base class for V3 workflow-trace consumers.
A Consumer reads a WorkflowTrace (attached to ExecutionHandle.trace
after a run) and converts it into whatever target-specific form it wants
— a directory tree on disk, a batched HTTP POST to Langfuse, a Loki
stream, a PDF report, a dict for a custom UI, and so on.
Base contract is deliberately small:
- subclasses override :meth:
consume, called once the run ends - a live consumer also overrides :meth:
on_startand/or :meth:on_execution, called as the run starts and as each execution lands, so a store can show a run while it goes - :meth:
sanitize, :meth:offload_media, :meth:truncateare shared utilities every consumer will want but nothing forces you to use them
No I/O, no state, no coupling to any specific backend. Consumers that DO have state (buffered clients, TTL sweepers, whatever) manage it themselves in their subclass — the base is pure.
See docs/TRACING_V3_DESIGN.md §3-5 for the full design.
Classes¶
Consumer
¶
Bases: ABC
Base class for any V3 workflow-trace consumer.
Subclasses override :meth:consume. The base offers three shared
utilities that every real consumer tends to need:
- :meth:
sanitize— strip non-JSON-serialisable values so the payload can round-trip throughjson.dumps/ Parquet / Langfuse's ingest API. - :meth:
offload_media— replace large binary payloads (audio, numpy arrays, model outputs) with content-addressed refs and write the raw bytes to a media directory. Dedup is automatic. - :meth:
truncate— cheap string truncation with a hint of the dropped length.
Rules for the base:
- No I/O, no side effects — every helper is a pure function.
- No shared state on the instance beyond
self.config(a plain dict handed in at__init__). - Every helper is opt-in — a minimal consumer can override
consumealone and ignore the rest.
Example — a trivial in-memory consumer::
class DictConsumer(Consumer):
def consume(self, trace):
return {n.op_id: n.op_name for n in trace.nodes}
Source code in operonx/telemetry/consumer.py
Attributes¶
live
property
¶
True when this consumer overrides :meth:on_start or
:meth:on_execution: the engine then hands it the run as it goes.
Methods:¶
on_start
¶
A run began; trace is its live trace (no nodes yet). Optional.
Called on the event loop, inside Operon.start: queue the work
and return — a database call here would stall every run in the
process. An exception is logged and never reaches the run.
Source code in operonx/telemetry/consumer.py
on_execution
¶
One execution was recorded. Optional.
Called on the event loop right after execution is appended to
trace.nodes, so its index there — what a store keys the row by,
and what the final write keys it by too — is
len(trace.nodes) - 1 while this runs. Queue and return, as for
:meth:on_start. consume still gets the whole trace at the end.
Source code in operonx/telemetry/consumer.py
consume
abstractmethod
¶
Convert trace to a target-specific artefact.
Return whatever makes sense for the target — a Path (files
written), URL (posted to a service), dict (in-memory view),
bytes (a rendered blob). The caller decides what to do with it.
Should raise on unrecoverable errors so the caller can log + continue with the next consumer.
Source code in operonx/telemetry/consumer.py
sanitize
¶
Recursively strip non-JSON-serialisable values.
Anything that isn't str/int/float/bool/None/dict/list/tuple
or a bytes-like object (which offload_media handles) gets
replaced with {"$unserializable": "<type_name>"}. Nested
dicts/lists are walked in place.
Numpy arrays go through unchanged — offload_media will
recognise + offload them. Callers that don't want numpy inline
should run offload_media FIRST.
Source code in operonx/telemetry/consumer.py
offload_media
¶
Recursively offload large binary values to media_dir.
Any value that's bytes / bytearray / memoryview /
numpy.ndarray / operonx.core.media.Media above threshold
bytes gets:
- serialised to bytes,
- hashed with SHA-256,
- written to
media_dir/<sha256>.<ext>(natural dedup — same bytes → same file, atomicopen("xb")withEEXISTtolerated), - replaced in the payload with
{"$media_ref": "media/<sha256>.<ext>", "size": <bytes>}.
Payloads smaller than threshold stay inline so the trace
remains greppable for small state dicts. Returns a new payload
(input is not mutated).
Source code in operonx/telemetry/consumer.py
truncate
¶
Cap s at limit chars, appending a length hint if dropped.
Concrete consumers¶
local
¶
LocalConsumer — generic disk-based V3 workflow-trace consumer.
Writes each run to its own directory under <root>, filed by where
the run came from (its origin, see :mod:operonx.app.origin), in a
layout designed to be read by humans (view.txt) and machines
(nodes.jsonl) with zero tooling:
<root>/ default: <project>/.operonx/runs
services/<service>/<YYYY-MM-DD>/<trace_id>/
jobs/<job>/<job_run>/<trace_id>/
evals/<job>/<job_run>/<trace_id>/
playground/<YYYY-MM-DD>/<trace_id>/
adhoc/<workflow>/<YYYY-MM-DD>/<trace_id>/
meta.json — workflow name, timings, metadata (origin…),
status ("ok"/"error") and the run's errors
nodes.jsonl — source of truth (one OpExecution per line,
media offloaded to refs; a generator's later
records name the first in ``inputs_from``
instead of repeating its inputs)
view.txt — human-readable chronological rendering
(regeneratable from nodes.jsonl at any time)
media/ — content-addressed offload store
<sha256>.<ext>
latest -> <the most recent run's directory>
layout: flat keeps the old <root>/<trace_id>/ shape; any other
string is a template over {origin}, {name}, {group},
{day}, {trace_id} and the run's metadata keys. Days are UTC.
Nothing here is callbot-specific — a subclass (CallbotLocalConsumer)
overrides _render_view to add turn-grouped headers derived from
operonx ctx. See docs/TRACING_V3_DESIGN.md §4-5.
Concurrency / safety:
- Each trace writes to a per-trace subdirectory → no shared file, no locking.
- Content-hashed media dedups across traces (create-exclusive on the
hash filename, ignore
FileExistsError). - Writes go to
<trace_id>.tmp/first; atomic rename on success → callers either see a complete directory or nothing. - TTL cleanup is external (systemd timer or cron) — the consumer just writes, it doesn't own retention policy.
Classes¶
LocalConsumer
¶
Bases: Consumer
Writes one directory per run under root.
Config keys (all optional, sensible defaults):
root(str | Path) — base directory. Unset (or empty), it is$OPERONX_RUNS_DIR, else<project>/.operonx/runs— the project anApplicationbootstrapped, or the nearestoperonx.tomlat or above the working directory (a script run inside a project) — else/tmp/operonx_traces. A relative root resolves against the project root.layout(str) —origin(default),flat, or a template; see the module docstring.media_threshold(int) — bytes; payloads at or above this get offloaded tomedia/. Defaults to1024.write_view_txt(bool) — set False to skip the human-readable render (rawnodes.jsonlonly). Defaults toTrue.arrow_formatters(dict[str, Formatter]) — per-op summary formatters merged over the built-inFORMATTERS. Any op-name not in the map falls back to :func:default_arrow.
Returns the path to the final directory on success.
Source code in operonx/telemetry/consumer.py
LocalConsumerConfig
¶
Bases: YamlModel
YAML-configurable :class:LocalConsumer.
Functions:¶
resolve_root
¶
The directory runs are written under — see root in
:class:LocalConsumer.
Source code in operonx/telemetry/consumers/local.py
run_path
¶
The run's directory relative to the root, from its metadata.
Source code in operonx/telemetry/consumers/local.py
default_arrow
¶
Fallback per-op arrow when nothing custom is registered.
Just shows the input / output key names — enough to see the shape of the op without spamming values.
Source code in operonx/telemetry/consumers/local.py
langfuse
¶
LangfuseConsumer — ship a WorkflowTrace to Langfuse as the tree the run actually had.
The record is flat: one :class:OpExecution per execution, with the
scheduler's ctx and every upstream edge. Langfuse is a tree viewer,
so this consumer builds the tree that already exists in ctx — a
generator's yield record carries the ctx it dispatched, which makes it
the container of everything that ran for that item — and never guesses
one from data edges (see docs/TRACING_CTX_TREE_PLAN.md §2).
Parent of a record, first hit wins:
- ctx
main(session-long ops): the trace itself. - a level-1 yield (
main.[i]) with no upstream that resolves to a record: the trace itself. (Refs to the root graph's inputs point atengine#main, which is never a record.) - the upstream producer whose ctx is a prefix of, or equal to, this ctx and that started first.
- the yield record at this ctx, then at each shorter prefix down to depth 2 — this is what catches an op whose edges stop at a GraphOp boundary.
- else a stand-in span for the unrecorded yield
main.[i], named after the root-level transient stream (audio_in [357]). Transient streams write no per-item record, so this is the only synthetic node a run needs besides graph containers.
Members of a GraphOp (engine.agent_turn.*) nest under one container
span per (graph, ctx). Names are op names; a yield is synthesize [2].
Observation ids are f"{run_id}/{op_id}" — op_id alone is the same
string in every run of the same graph, and Langfuse ids are unique per
project, so a bare op_id let each call overwrite the spans of the
call before it. Times come from the trace's wall-clock anchor. An
LLMOp record is a generation (model, usage); everything else a
span. Runs post-call, one batch; per-event errors in Langfuse's 207
reply are logged, not dropped.
Example resources.yaml::
trace_langfuse:
edupia:
client_resource: langfuse:edupia
workflow_name: callbot
Classes¶
LangfuseConsumer
¶
Bases: Consumer
Ship a whole :class:WorkflowTrace to Langfuse as one batch.
Config keys:
client(LangfuseClient) — REQUIRED; injected by the ResourceHub factory fromclient_resource.workflow_name— the Langfuse trace name; defaults to the run's.media_threshold— bytes above which a payload becomes a local$media_reftoken (default 1024).media_dir— where those go.
Returns the trace URL.
Source code in operonx/telemetry/consumer.py
LangfuseConsumerConfig
¶
Bases: YamlModel
YAML-configurable :class:LangfuseConsumer.
parent_strategy is accepted and ignored: the tree comes from ctx
now. It stays in the schema so an existing resources.yaml keeps
loading; remove it at leisure.
Functions:¶
build_tree
¶
Every observation of the run, keyed by id, with its parent set.
Source code in operonx/telemetry/consumers/langfuse.py
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 | |
Langfuse backend¶
Low-level Langfuse HTTP client + prompt manager — reused by
LangfuseConsumer for shipping traces, and available standalone for
prompt fetching.
backends
¶
Observability backends.
Each backend provides
- Config class: registered to ResourceHub via the YAML config layer
- Client class: HTTP/grpc transport, used by exporters
Available backends
- langfuse: Langfuse observability platform
Classes¶
LangfuseClient
¶
Langfuse client using the public REST API for tracing.
Pure HTTP — no SDK dependency. Used by LangfuseTracer for trace ingestion.
Example
Source code in operonx/telemetry/backends/langfuse/client.py
Methods:¶
ingest
¶
Send a batch of events to Langfuse ingestion API.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
batch
|
List[Dict[str, Any]]
|
List of ingestion events (trace-create, span-create, etc.) |
required |
timeout
|
int
|
Request timeout in seconds |
30
|
Returns:
| Type | Description |
|---|---|
Dict[str, Any]
|
Response dict with 'successes' and 'errors' lists |
Source code in operonx/telemetry/backends/langfuse/client.py
upload_media
¶
upload_media(
*,
trace_id: str,
field: str,
content_type: str,
content: bytes,
observation_id: Optional[str] = None,
timeout: int = 30,
) -> Optional[str]
Upload a media blob and return its Langfuse media reference token.
Two-step flow per Langfuse docs
- POST /api/public/media with metadata → get mediaId + presigned URL.
- PUT the raw bytes to the presigned URL with the required headers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trace_id
|
str
|
Trace this media belongs to. |
required |
field
|
str
|
Where the media lives — |
required |
content_type
|
str
|
MIME type, e.g. |
required |
content
|
bytes
|
Raw bytes to upload. |
required |
observation_id
|
Optional[str]
|
Optional observation (span / generation) ID. |
None
|
timeout
|
int
|
HTTP timeout per request. |
30
|
Returns:
| Type | Description |
|---|---|
Optional[str]
|
The reference token string |
Optional[str]
|
|
Optional[str]
|
or |
Source code in operonx/telemetry/backends/langfuse/client.py
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | |
trace_url
¶
fetch_trace
¶
GET /api/public/traces/{traceId} — fetch a posted trace for verification.
Returns the parsed JSON body (trace + nested observations) on success, or None if the trace is not found / unauthorized / unreachable. Used by integration tests to verify trace structure landed correctly.
Source code in operonx/telemetry/backends/langfuse/client.py
auth_check
¶
Check authentication by hitting the health endpoint.
Returns:
| Type | Description |
|---|---|
bool
|
True if authentication is successful |
Source code in operonx/telemetry/backends/langfuse/client.py
LangfuseConfig
¶
Bases: YamlModel
Configuration for Langfuse observability backend.
This config is registered to ResourceHub and used to create LangfuseClient.
Attributes:
| Name | Type | Description |
|---|---|---|
public_key |
str
|
Public API key for Langfuse authentication |
secret_key |
str
|
Secret API key for Langfuse authentication |
host |
str
|
Langfuse server URL (default: cloud.langfuse.com) |
no_proxy |
Optional[str]
|
Proxy bypass setting for internal networks |
enabled |
bool
|
Whether tracing is enabled |
sample_rate |
float
|
Sampling rate for traces (0.0 to 1.0) |
Example
Methods:¶
from_env
classmethod
¶
Create config from environment variables.
Environment variables
- LANGFUSE_PUBLIC_KEY
- LANGFUSE_SECRET_KEY
- LANGFUSE_HOST (optional)
- NO_PROXY (optional)
Source code in operonx/telemetry/backends/langfuse/config.py
LangfusePromptManager
¶
Manages Langfuse prompts via the official SDK.
Requires the langfuse package (pip install operonx-telemetry[langfuse]).