Observability — Structured Logging & OpenTelemetry¶
tuvl ships enterprise-grade observability out of the box: structured JSON logging via structlog and distributed tracing via OpenTelemetry (OTel). Both are active in production and automatically disabled in dev mode.
Table of Contents¶
- Architecture Overview
- Structured Logging
- Distributed Tracing
- Span Hierarchy
- LiteLLM GenAI Telemetry
- Agent Metrics
- Configuration Reference
- Collector Setup
- Dev Mode Behaviour
- Data Masking & PII
1. Architecture Overview¶
HTTP request
│
▼
┌─────────────────────────────────────────────┐
│ FastAPI (automatic HTTP spans) │ ← opentelemetry-instrumentation-fastapi
│ W3C traceparent header propagation │ ← TraceContextTextMapPropagator
└───────────────────┬─────────────────────────┘
│
▼
workflow.execute span
│
┌──────────┴──────────┐
▼ ▼
node.Functional node.Agent span
span │
▼
LiteLLM gen_ai.* spans ← litellm OTel callback
(model, tokens, latency)
All log events emitted during a request are automatically annotated with trace_id and span_id so they can be correlated with spans in any OTel-compatible backend (Jaeger, Grafana Tempo, Honeycomb, Datadog, etc.).
2. Structured Logging¶
tuvl uses structlog for all log output. In production, every line is a single-line JSON object. In development mode a human-readable coloured renderer is used instead.
Log format — production¶
{
"event": "Workflow execution started",
"level": "info",
"logger": "tuvl.core.engine.runner",
"timestamp": "2026-05-26T10:00:00.000000Z",
"workflow": "recruitment-pipeline",
"trace_id": "4a8f3e...",
"span_id": "8b2c1d..."
}
Log format — development (TUVL_ENV=development)¶
2026-05-26T10:00:00Z [info ] Workflow execution started workflow=recruitment-pipeline trace_id=4a8f3e...
Key log events¶
| Event | Level | Extra fields |
|---|---|---|
Workflow execution started |
info | workflow |
Executing workflow node |
info | workflow, node_id, node_kind |
Workflow execution complete |
info | workflow, signal |
Agent LLM call |
info | step_id, model, attempt, max_attempts |
Agent LLM response |
info | step_id, model, input_tokens, output_tokens |
Agent LLM retry |
info | step_id, attempt, max_attempts, wait_s, last_signal |
Agent LLM timeout |
warning | step_id, timeout_s |
Agent LLM error |
warning | step_id, exc_type, error |
Agent run complete |
info | step_id, signal |
autonomous_agent.turn |
info | step_id, iteration, max_iterations, tool_calls, tokens_used |
autonomous_agent.tool_call |
info | step_id, iteration, tool, signal |
autonomous_agent.llm_error |
warning | step_id, iteration, exc_type, error |
autonomous_agent.budget_exceeded |
warning | step_id, tokens_used, token_budget |
autonomous_agent.max_iterations |
warning | step_id, max_iterations |
OTel TracerProvider initialised |
info | service, endpoint |
FastAPI OpenTelemetry instrumentation active |
info | — |
LiteLLM OpenTelemetry callback registered |
info | — |
During an AutonomousAgent step, run_streaming also emits live progress frames over SSE/gRPC: StepEvents with signal="running" and an agent_progress snapshot payload ({type: iteration|tool_call|outcome, ...}) — loop metadata only (no context values), so they add no PII surface beyond the masked final frame. The terminating frame carries the real outcome signal and the masked context snapshot as usual.
Using the logger in custom nodes¶
import structlog
logger = structlog.get_logger(__name__)
async def my_node(context):
logger.info("Processing candidate", candidate_id=context["id"], step="enrich")
# ...
return context, "default"
Always pass extra data as keyword arguments — never use f-strings. This ensures the values appear as structured fields in JSON output and are automatically correlated with the active trace span.
3. Distributed Tracing¶
tuvl uses the OpenTelemetry SDK with a gRPC OTLP exporter. The global TracerProvider is installed at startup via init_telemetry() (called from the FastAPI lifespan).
Propagation¶
Inbound HTTP requests are checked for a W3C traceparent header. When present, the new spans are created as children of the upstream trace, enabling end-to-end distributed traces across services.
Span attributes set by tuvl¶
| Attribute | Span | Description |
|---|---|---|
tuvl.workflow.name |
workflow.execute |
Workflow YAML name |
tuvl.node.id |
node.* |
Step ID from YAML |
tuvl.node.kind |
node.* |
Step kind: Agent, Functional, APICall, etc. |
tuvl.step.signal |
node.* |
Route signal returned by the step |
tuvl.step.duration_ms |
node.* |
Wall-clock execution time in milliseconds |
tuvl.context.snapshot |
node.* |
JSON snapshot of public context fields (PII masked) |
tuvl.agent.iteration |
autonomous_agent.iteration |
Loop iteration index (1-based) for an AutonomousAgent step |
tuvl.agent.tokens_used |
autonomous_agent.iteration |
Cumulative tokens consumed by the agent loop so far |
tuvl.agent.tool_calls |
autonomous_agent.iteration |
Number of tool calls the model requested this turn |
tuvl.agent.tool |
autonomous_agent.tool_call |
Name of the tool (component ref) invoked |
tuvl.agent.tool_signal |
autonomous_agent.tool_call |
Signal returned by the dispatched tool component |
4. Span Hierarchy¶
Every workflow execution produces a consistent span tree:
workflow.execute (1 span per workflow run)
├── node.Functional (1 per functional step)
├── node.Agent (1 per agent step)
│ ├── litellm.completion [gen_ai.*] (1+ per LLM call / retry)
│ └── ...
├── node.AutonomousAgent (1 per AutonomousAgent step)
│ ├── autonomous_agent.iteration (1 per loop turn)
│ │ ├── litellm.completion [gen_ai.*] (the turn's LLM call)
│ │ └── autonomous_agent.tool_call (1 per tool the model invoked this turn)
│ └── ... (further iterations)
├── node.APICall
├── node.MCP
└── ...
The node.{kind} span is opened before the step executes and closed after, so that all LiteLLM calls made during an Agent step are automatically nested as children. This gives accurate per-step attribution of token usage and latency.
For an AutonomousAgent step, each loop turn opens an autonomous_agent.iteration span (carrying the iteration index and cumulative tuvl.agent.tokens_used), under which the turn's litellm.completion and any autonomous_agent.tool_call spans nest — giving per-iteration cost and per-tool attribution across the whole agent loop.
5. LiteLLM GenAI Telemetry¶
When telemetry is enabled, tuvl registers LiteLLM's built-in OpenTelemetry callback:
LiteLLM (≥ 1.50) automatically picks up the globally registered TracerProvider and emits spans with OpenTelemetry GenAI semantic conventions:
| Attribute | Description |
|---|---|
gen_ai.system |
Provider: openai, anthropic, etc. |
gen_ai.request.model |
Model name passed to the API |
gen_ai.usage.input_tokens |
Prompt token count |
gen_ai.usage.output_tokens |
Completion token count |
gen_ai.response.finish_reason |
Stop reason |
These spans appear as children of the node.Agent (or node.AutonomousAgent) span in your tracing backend, giving you per-call token usage, latency, and cost attribution without any additional instrumentation code.
6. Agent Metrics¶
Alongside spans, the agent runtime emits OTel counters (meter tuvl.agent) so
agent health is dashboardable and alertable. They are created on a proxy meter at
import time and stay inert no-ops until init_telemetry() installs a
MeterProvider (production run mode); metrics export over the same gRPC OTLP
endpoint as traces via a PeriodicExportingMetricReader.
| Counter | Incremented when | Attributes |
|---|---|---|
tuvl.agent.iterations |
An AutonomousAgent loop completes a turn |
tuvl.agent.step_id |
tuvl.agent.tool_calls |
The model invokes a declared tool | tuvl.agent.step_id, tuvl.agent.tool |
tuvl.agent.aborts |
A run is aborted by a supervisor or operator | tuvl.agent.step_id |
tuvl.agent.budget_exceeded |
A run hits its token_budget |
tuvl.agent.step_id |
tuvl.agent.supervisor_actions |
A supervisor intervenes (abort / pause / steer) | action, source (rule kind or judge) |
tuvl.agent.judge_failures |
A supervisor LLM-judge call errors or times out | workflow |
A sustained rise in tuvl.agent.aborts or tuvl.agent.judge_failures is the
signal to inspect the run traces (autonomous_agent.iteration spans) or the
Insight Agents dashboard.
7. Configuration Reference¶
All settings are read from .env or environment variables. Settings also support a per-project YAML override file at .tuvl/telemetry.yaml (see below).
Environment variables¶
| Variable | Default | Description |
|---|---|---|
TUVL_TELEMETRY_ENABLED |
true |
Set false to disable all OTel export |
TUVL_OTLP_ENDPOINT |
http://localhost:4317 |
gRPC OTLP collector endpoint |
OTEL_EXPORTER_OTLP_ENDPOINT |
— | Standard OTel env var — takes precedence over TUVL_OTLP_ENDPOINT when set |
TUVL_SERVICE_NAME |
tuvl |
service.name resource attribute in every span |
TUVL_ENV |
production |
production → JSON logs; development → coloured console logs |
.tuvl/telemetry.yaml override¶
Values from this file are applied only when the corresponding environment variable is not set, making it suitable for checked-in defaults that operators can override at deployment time.
kind: TelemetryConfig
version: v1
spec:
enabled: true
otlp_endpoint: http://otel-collector:4317
service_name: my-tuvl-app
Config resolution order¶
For each setting, the first non-empty source wins:
- Environment variable (
TUVL_TELEMETRY_ENABLED,OTEL_EXPORTER_OTLP_ENDPOINT,TUVL_OTLP_ENDPOINT,TUVL_SERVICE_NAME) .tuvl/telemetry.yamlspec fields- Hardcoded defaults (enabled=
true, endpoint=http://localhost:4317, service=tuvl)
8. Collector Setup¶
tuvl exports spans over gRPC to any OTLP-compatible collector. Below are quick-start configurations for common backends.
OpenTelemetry Collector (recommended)¶
# docker-compose.yml
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
ports:
- "4317:4317" # gRPC OTLP
volumes:
- ./otel-config.yaml:/etc/otelcol/config.yaml
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # Jaeger UI
# otel-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
exporters:
jaeger:
endpoint: jaeger:14250
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
exporters: [jaeger]
Grafana Tempo (via Alloy)¶
Honeycomb¶
OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io
# Set OTEL_EXPORTER_OTLP_HEADERS with your API key via collector or proxy
Datadog¶
Use the Datadog Agent as an OTLP collector:
9. Dev Mode Behaviour¶
When TUVL_DEV_MODE=true (set automatically by tuvl dev):
init_telemetry()is a no-op — noTracerProvideris installed- The OTel SDK default
NonRecordingSpanis returned for allstart_as_current_spancalls — all span operations become no-ops with negligible overhead - The LiteLLM OTel callback is not registered
- Logs use the coloured ConsoleRenderer instead of JSON
trace_id/span_idare not injected into log events (no valid span context)
You can enable production-style JSON logging in dev by setting TUVL_ENV=production without changing TUVL_DEV_MODE.
10. Data Masking & PII¶
Context snapshots attached to spans (tuvl.context.snapshot) are scrubbed before export:
- Private keys stripped — any context key starting with
_(e.g._session,_db_handle) is removed entirely - Secure fields masked — fields declared
secure: truein anyModelDefinitionare replaced with"*****"
# models/employee.yaml
spec:
fields:
- name: national_id
type: string
secure: true # → always "****" in spans and test snapshots
- name: salary
type: numeric
secure: true
The same masking is applied to the Tuvl Spectrum execution trace, so PII never appears in developer tooling either.
The masking registry (SECURE_FIELDS) is populated at startup from all loaded ModelDefinition files and is additive — fields from all models contribute to a single global set.