← All posts

Logging and Observability for a Team of AI Agents

What to trace when many agents run: per-org, per-agent, per-feature cost, tool calls linked to a workflow, and the schema behind it.

Vocion Teamvocion-v2.47.4

Observability for a team of AI agents is not "add a tracer" — that part is solved. It is deciding what to tag on every call so cost, latency, and behavior can be sliced by org, by agent, by feature, and by the mission or workflow that triggered it. Vocion's answer: every LLM call — an agent turn, a skill run, an eval judge, a feedback classification — goes through one helper that stamps userId, metadata.orgId, and tags feature:<name>, org:<orgId>, slug:<agentOrOperationSlug> onto a self-hosted Langfuse trace. Separately, every tool call a workflow makes is a Postgres row carrying agent_slug, lead_agent_slug, mission_run_id, workspace_sha, and the langfuse_trace_id that row's cost lives under — so a tool call, a mission run, and a dollar figure are one join away from each other.

This is for the engineer who already knows Langfuse, or something like it, exists. You've read the SDK docs. What they don't answer is the harder question: once more than one agent is running, what do you actually put on every span so a bill or an incident traces back to one agent, one team, one mission — not just "some LLM call happened somewhere." Vendor docs for Langfuse, LangSmith, or Arize explain their own API surface. None of them tell you which dimensions matter once you have a workforce instead of a single chatbot.

What is the closed set of "feature" dimensions?

Vocion answers the tagging question with a closed enum, not free strings. It lives at packages/core/src/libs/Langfuse/features.ts, and the file's own header comment states the rule plainly:

/**
 * Closed enum of "feature" dimensions stamped on every Langfuse trace.
 *
 * Adding a new feature MUST mean editing this file, not passing a free
 * string at the call site. That's what keeps the Langfuse UI's
 * `tags = feature:<name>` filter useful for slicing cost / volume by
 * surface (chat vs. operation vs. eval).
 */

The enum itself declares twelve values: agent.chat, agent.dev, operation.run, eval.judge, workflow.step, feedback.classify, chat.chip-synthesis, source.oauth, retrieval.search, retrieval.embed, retrieval.ingest, retrieval.rerank.

Declaring a feature and wiring it are two different things, and it is worth checking which is which before you build a dashboard filter around one. Grepping packages/ for FEATURES.<NAME> call sites against the current tag shows eight of the twelve actually stamped on a trace today: AGENT_CHAT (services/AgentService.ts), EVAL_JUDGE (services/EvalService.ts), FEEDBACK_CLASSIFY (services/feedback/classifier.ts), CHIP_SYNTHESIS (services/chat/synthesis.ts), and the four retrieval features — RETRIEVAL_SEARCH (services/RetrievalService.ts), RETRIEVAL_EMBED (libs/retrieval/embedder.ts), RETRIEVAL_INGEST (services/IngestionService.ts), and RETRIEVAL_RERANK (libs/retrieval/reranker.ts). Four are declared with no call site in the codebase as of this version: AGENT_DEV, OPERATION_RUN, SOURCE_OAUTH, and — the one worth flagging explicitly since it's the one people ask about when they see "workflow" in a tagging doc — WORKFLOW_STEP. It is a reserved slot in the enum, not a live trace. No workflow step currently emits a workflow.step span. Treat any reference to per-step workflow tracing as forward-looking until a call site shows up.

What is the actual trace shape?

The tagging contract is one function, traceFor, documented in its own comment in packages/core/src/libs/Langfuse.ts:

/**
 * Create a Langfuse trace with the standard Vocion tagging shape:
 *
 *   name      = `${feature}:${slug}`
 *   userId    = caller-supplied (never undefined — use 'system' /
 *               'worker' / 'eval-runner' / 'mcp' for non-interactive)
 *   metadata  = { orgId, feature, slug, ...callerMetadata }
 *   tags      = [`feature:${feature}`, `org:${orgId}`, `slug:${slug}`]
 */

Every call site supplies a feature from the closed enum, a slug (agent, operation, or dataset identifier), an orgId, and a userId that is never left undefined — non-interactive callers pass a fixed string like 'system' or 'eval-runner' instead. That single rule is what makes an org filter, a user filter, and a feature filter all work against the same trace without special-casing background jobs.

Langfuse traces answer "what happened in this call." They don't answer "which mission run made this call, and what did it cost against that mission's tool budget." That join lives in Postgres, in the tool_call table (packages/core/src/models/Schema.ts, pgTable('tool_call', ...)). Relevant columns:

Three indexes back the common queries: tool_call_org_created_idx (org, created_at), tool_call_org_agent_idx (org, agent_slug), and tool_call_org_tool_idx (org, tool). A tool call row names its mission run and its Langfuse trace in the same row, so you don't reconstruct that join from timestamps.

What does per-agent cost attribution look like day to day?

/dashboard/observability is a real page (packages/core/src/app/[locale]/(auth)/dashboard/observability/page.tsx), not a mockup. It's deliberately thin — the comment in the file calls it "a launch pad with three numbers that match the day-to-day questions... + saved-filter deep-links into Langfuse." Those three numbers are "Spend this period" (summed across listAgentBudgets), "Runs (last 24h)" (tool calls plus workflow runs, via ObservabilityService.countRunsLast24h, which counts rows in tool_call and workflow_run since now - 24h), and "Active agents" (agents with non-zero spend in the current period). Below that sits a "Top agents by spend" table, sorted by currentCents descending, each row linking out to Langfuse pre-filtered by slug:<agentSlug>. The page also renders two one-click buttons that build a Langfuse URL filtered by org:<orgId> and by name: 'agent.chat' or name: 'operation.run' respectively (FILTER_ENCODE in the same file) — useful shortcuts, with the caveat above that operation.run has no current call site, so that particular saved filter returns nothing until one exists.

How do budgets cap runaway spend before it happens?

BudgetService.preflightCheck (packages/core/src/services/BudgetService.ts) runs before an agent turn and checks a per-agent, per-period (daily or monthly) budget row for a hard token or cent ceiling. If no budget row exists for that org/agent/period, it returns { ok: true } — budgets are opt-in, not a default cap. chargeUsage runs after, from the Langfuse callback's on_chat_model_end hook, incrementing the period's token and cent counters; its own doc comment notes it is "no-op (returns silently) when no budget row exists yet." Period rollover is checked lazily on charge, comparing UTC year/month/day against periodStartedAt, so nothing needs a cron tick just to reset a counter.

How do you self-host the tracer?

One command. The root docker-compose.yml includes infra/docker-compose.platform.yml, so docker compose up -d brings a full Langfuse stack online next to the app: web, worker, its own Postgres, ClickHouse for trace storage, Redis for the ingestion queue, and MinIO for large payload blobs. A stack with an OpenTelemetry Collector container also ships in that same compose file (otel-collector, listening on 4317/4318); as of this version we did not find application code sending spans to it, so treat it as present in the stack rather than as a wired tracing path. First boot takes 60-90 seconds while migrations run. Once it's up, verify with:

npm run langfuse:smoke

This creates a trace, a generation, and a span, flushes them, then polls /api/public/traces/<id> until the row lands in ClickHouse, exiting non-zero on failure — safe to drop into a dev:up hook or CI. Reasonable sizing, from infra/README.md: Postgres 1GB RAM for small demo datasets, Langfuse 4GB (ClickHouse plus its own Postgres), Temporal 2GB, roughly 8GB+ recommended for the full stack. The complete boot sequence, including the auto-init project defaults and the one-time npm run langfuse:bootstrap step for pricing newer models, is in /docs/guides/observability — read that for setup, this page for what to tag and why.

An adjacent question is not "what did this call cost" but "what version of the agent produced this output" — see AI Agent Versioning and Audit Trail, which covers the workspace_version rows and the same workspace_sha column that shows up on every tool_call row above. Both pages answer "trace this output back to what produced it," one by cost and call trace, the other by version.

If you're setting up a new agent workforce, decide your tagging dimensions before your first production run, not after the first unexplained bill.