Skip to main content

Logging and metrics

AgentFlow ships three separate observability surfaces. This page covers two of them:

SurfaceWhat it gives youWhere
LogsOne JSON object per line, correlated to a run, thread, user, and node, with secrets redacted.This page
MetricsCounters and timers for node executions, tool calls, and checkpointer writes, exportable to OpenTelemetry.This page
TracesSpans for each run, node, and tool call, sent to an OTEL collector, Logfire, or LangSmith.Send traces to Logfire or LangSmith

Logs and metrics are independent of tracing. You can enable either without installing OpenTelemetry, and both keep working if the tracing backend is down.


Structured logging

The library never configures logging by itself, so nothing changes until you opt in. Call setup_structured_logging() once at startup, before the graph handles its first request.

import logging
from agentflow.utils.logging import setup_structured_logging

setup_structured_logging(
level=logging.INFO,
json_format=True,
redact_secrets=True,
logger_name="agentflow",
)
ParameterTypeDefaultDescription
levelintlogging.INFOLevel applied to both the logger and the installed handler.
json_formatboolTrueEmit one JSON object per line. When False, a plain-text format carrying the same correlation fields is used instead.
redact_secretsboolTrueAttach the secret redaction filter to the handler.
logger_namestr"agentflow"Logger to configure.

It returns the installed logging.Handler so you can attach your own filters or swap the stream.

What a record looks like

{
"timestamp": "2026-07-21 10:14:02,881",
"level": "INFO",
"logger": "agentflow.agent",
"message": "Node 'MAIN' completed",
"run_id": "run_01H...",
"thread_id": "thread-42",
"user_id": "alice",
"node": "MAIN"
}

The four correlation fields (run_id, thread_id, user_id, node) are what make logs queryable: "show me every ERROR for thread-42" is a filter rather than a grep. They are only present when they are in scope.

Anything you pass through extra= is merged into the same object, provided it is JSON-serialisable:

logger.info("Charged customer", extra={"amount_cents": 4900, "invoice": "inv_123"})

Exceptions logged with exc_info=True land under an exception key.

How correlation works

CorrelationFilter reads the fields from context variables and stamps them onto every record. It is a filter rather than a formatter on purpose: the fields end up on the LogRecord itself, so they are visible to any downstream formatter, handler, or APM agent, not only to AgentFlow's JSON formatter.

The execution loop binds these fields for you at the start of a run and again when entering a node. Bind them yourself when logging from outside a graph run:

from agentflow.utils.logging import bind_log_context_from_config, get_log_context, set_log_context

bind_log_context_from_config(config) # reads run_id, thread_id, user_id
set_log_context(node="my_background_worker") # or set fields individually

get_log_context() # -> {"run_id": ..., "thread_id": ..., "node": ...}

Because the fields live in context variables, they are per-async-context and do not leak between concurrent requests.

Secret redaction

SecretRedactionFilter scrubs the formatted message before it is emitted. It masks OpenAI, Google, GitHub, Slack, and AWS key formats, Bearer tokens, key=value secrets, and credential query parameters in signed URLs.

Attach it to a handler, not a logger. Python applies logger-level filters only to records emitted directly on that logger, so a logger-level filter misses every child logger:

from agentflow.utils.logging import SecretRedactionFilter, install_secret_redaction, mask_secrets

handler.addFilter(SecretRedactionFilter()) # preferred: covers all child loggers
install_secret_redaction("agentflow") # convenience wrapper, returns the filter
mask_secrets(some_string) # redact an arbitrary string

setup_structured_logging(redact_secrets=True) already does the handler-level attachment.

This is a heuristic safety net. It will not catch every possible secret and can over-redact. Do not treat it as a substitute for keeping credentials out of log messages.


Metrics

agentflow.utils.metrics is a zero-dependency, thread-safe in-process registry. It is always recording; the framework already instruments node executions, tool calls, background tasks, and checkpointer writes.

from agentflow.utils.metrics import counter, timer, snapshot

counter("orders.processed").inc()
counter("orders.processed").inc(3, attributes={"channel": "web"})

with timer("db_write_latency_ms"):
await write()
CallableSignatureDescription
counter(name: str) -> CounterGet or create a named counter.
timer(name: str, attributes: dict | None = None)Context manager that records the elapsed milliseconds of a block.
snapshot() -> dictThread-safe point-in-time copy of every counter and timer.
enable_metrics(value: bool) -> NoneGlobal on/off switch. When off, inc and observe return immediately.
setup_otel_metrics(meter: Any = None) -> boolBridge the registry to OpenTelemetry.

Counter exposes inc(amount=1, attributes=None) and a value attribute. TimerMetric exposes observe(duration_ms, attributes=None) plus count, total_ms, max_ms, and an avg_ms property.

timer tags each observation with an outcome attribute of "ok" or "error", so success and failure latencies can be separated. A p99 that mixes them is not actionable. It does not suppress exceptions.

Reading metrics without an exporter

from agentflow.utils.metrics import snapshot

snapshot()
# {
# "counters": {"agentflow.node.executions": 128, "agentflow.tool.errors": 2},
# "timers": {"agentflow.node.duration": {"count": 128, "avg_ms": 412.7, "max_ms": 2891.0}},
# }

This is enough for a /metrics-style debug endpoint or a health check. It is process-local: the numbers die with the process and are not aggregated across replicas.

Exporting to OpenTelemetry

pip install "10xscale-agentflow[otel]"
from agentflow.utils.metrics import setup_otel_metrics

setup_otel_metrics() # once, at startup

Call it once at startup, after your application has configured a MeterProvider with its exporter. From then on every existing counter(...) and timer(...) call site exports automatically, with no change at the call site: counters become OTEL counters, timers become histograms with unit ms, and attributes become dimensions.

Pass an explicit meter to use a specific one; otherwise a meter named agentflow is taken from the global MeterProvider.

setup_otel_metrics() returns False and logs at info level when OpenTelemetry is not installed. The in-process registry keeps working, so this is safe to call unconditionally.

Instrumented metrics

MetricTypeAttributes
agentflow.node.executionscounternode
agentflow.node.errorscounternode
agentflow.node.timeoutscounternode
agentflow.node.stoppedcounternode
agentflow.node.durationtimernode, outcome
agentflow.tool.callscounternode, tool
agentflow.tool.errorscounternode, tool
agentflow.tool.timeoutscounternode, tool
agentflow.tool.durationtimernode, tool, outcome
background_task_manager.tasks_createdcounter
background_task_manager.tasks_completedcounter
background_task_manager.tasks_failedcounter
background_task_manager.tasks_droppedcounter
background_task_manager.tasks_cancelledcounter
background_task_manager.tasks_timed_outcounter
pg_checkpointer.save_state.attempts / .success / .error / .conflictcounter
pg_checkpointer.save_state.durationtimer
pg_checkpointer.save_checkpoint.attempts / .success / .error / .conflictcounter
pg_checkpointer.save_checkpoint.durationtimer

pg_checkpointer.save_state.conflict counts optimistic-concurrency rejections, and background_task_manager.tasks_dropped counts events shed under backpressure. Both are good alert candidates: a rising rate means concurrent writers are contending, or a publisher is not keeping up.

Telemetry failures are swallowed and logged at debug level. A broken exporter never breaks the caller.


Putting it together

import logging

from agentflow.utils.logging import setup_structured_logging
from agentflow.utils.metrics import setup_otel_metrics


def configure_observability() -> None:
setup_structured_logging(level=logging.INFO, json_format=True, redact_secrets=True)
setup_otel_metrics()


configure_observability()
# ... build and compile the graph

Add tracing on top when you want per-node spans: see Send traces to Logfire or LangSmith and the publishers reference.