Most teams adopt agents one developer at a time. The real pain starts when several people have fleets running and nobody can see what’s actually happening.
Most teams adopt agents one developer at a time. The real pain starts when several people have fleets running and nobody can see what’s actually happening.
This tutorial shows how to add metrics, logs, and traces around Maxxwell so a lead can answer three questions from a single view:
The focus is practical: shell commands, OpenTelemetry wiring, and a simple “agent workflow health” dashboard you can evolve.
By the end, you’ll have:
We’ll use:
This assumes you already know your way around Maxxwell and run multiple coding agents daily. If you’re still in “single Claude tab” land, this is overkill.
For more conceptual background on "agent-native" workflows, see the companion piece Agent-native development: a working definition - this tutorial is the wiring, that article is the mental model.
Surveys say adoption is ahead of trust:
In other words: you already run agents, they already ship code, and nobody fully trusts them.
At that point, the bottleneck isn’t “write more code”, it’s:
Google Cloud, Dynatrace and OpenTelemetry all say the same thing: agent systems need multi-signal observability - logs, metrics, and traces - not just console dumps.
Maxxwell already gives you an operational surface:
This tutorial just instruments that surface.
You’ll need:
We’ll treat Maxxwell as the control plane, and our telemetry sidecar as:
The core pattern works for any agent orchestrator (CommandSlate, Helmor, The Cog, your own tmux zoo). Maxxwell just happens to make the fleet topology explicit.
Borrowing from Fiddler’s agentic KPIs and OpenTelemetry’s AI guidance, we’ll start with:
Per-session metrics
agent_session_active{tool, repo} - gauge: 1 when session is working, 0 otherwise.agent_session_waiting_on_human{tool} - gauge: 1 when state is “waiting on you”.agent_session_stalled{tool} - gauge: 1 when Maxxwell shows “possibly stalled”.agent_session_errors_total{tool} - counter of session-level errors.Fleet-wide metrics
agent_tokens_total{tool, direction} - tokens in/out; Dynatrace explicitly tracks cost, and you should too.agent_workflow_duration_seconds{workflow} - end-to-end latency from brief to done.agent_handoffs_total{from, to} - orchestrator → worker, worker → worker.Logs
workflow_started, workflow_completed, workflow_failed.Traces
You can add more later, but this gets you a useful workflow health dashboard in a day.
Maxxwell runs your workers as real CLI sessions. For observability, treat them like any other process:
If you drive workers from a shell entrypoint, a simple pattern is (adapt the command to your own launcher):
# Example: start a worker session with logs redirected
/usr/local/bin/claude-code \
> logs/feature-123-api-refactor.out 2> logs/feature-123-api-refactor.err &
On desktop, you can instead:
~/.config/claude-code/logs/*.json).The invariant you want: every session has a stable ID, and you can find all its logs on disk.
We’ll build an agent-otel-sidecar that:
Install the basics (Node.js example):
npm init -y
npm install \
@opentelemetry/api \
@opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/exporter-logs-otlp-http \
chokidar
Create otel.ts:
// otel.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api';
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO);
const traceExporter = new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,
});
const metricExporter = new OTLPMetricExporter({
url: process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT,
});
const logExporter = new OTLPLogExporter({
url: process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT,
});
export const sdk = new NodeSDK({
traceExporter,
metricExporter,
logExporter,
serviceName: 'maxxwell-fleet',
});
export async function startOtel() {
await sdk.start();
}
export async function shutdownOtel() {
await sdk.shutdown();
}
Wire it into index.ts:
// index.ts
import { startOtel, shutdownOtel } from './otel';
import { startLogWatcher } from './log-watcher';
async function main() {
await startOtel();
await startLogWatcher();
process.on('SIGINT', async () => {
await shutdownOtel();
process.exit(0);
});
}
main().catch(async (err) => {
console.error(err);
await shutdownOtel();
process.exit(1);
});
Point OTEL_EXPORTER_OTLP_*_ENDPOINT at your collector, run the sidecar, and you’re ready to emit telemetry.
Assume each worker log line is either:
If you can, make your workers log structured events like:
{"ts":"2026-09-05T12:34:56Z","level":"info","sessionId":"feature-123","event":"model_call","tool":"claude-code","tokens_in":512,"tokens_out":1632}
Then write a simple watcher:
// log-watcher.ts
import chokidar from 'chokidar';
import fs from 'fs';
import readline from 'readline';
import { context, trace, metrics, logs } from '@opentelemetry/api';
const tracer = trace.getTracer('maxxwell-fleet');
const meter = metrics.getMeter('maxxwell-fleet');
const logger = logs.getLogger('maxxwell-fleet');
const tokenCounter = meter.createCounter('agent_tokens_total', {
description: 'Total tokens used by agents',
});
const sessionErrorCounter = meter.createCounter('agent_session_errors_total');
export async function startLogWatcher() {
const watcher = chokidar.watch('logs/*.out', { persistent: true });
watcher.on('add', (path) => tailFile(path));
}
function tailFile(path: string) {
const stream = fs.createReadStream(path, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream });
rl.on('line', (line) => handleLogLine(path, line));
}
function handleLogLine(path: string, line: string) {
let evt: any = { raw: line };
try {
evt = JSON.parse(line);
} catch {
// fall back to raw
}
const sessionId = evt.sessionId || inferSessionIdFromPath(path);
if (evt.event === 'model_call') {
tokenCounter.add(evt.tokens_in ?? 0, {
direction: 'in',
tool: evt.tool,
sessionId,
});
tokenCounter.add(evt.tokens_out ?? 0, {
direction: 'out',
tool: evt.tool,
sessionId,
});
}
if (evt.level === 'error') {
sessionErrorCounter.add(1, { sessionId, tool: evt.tool });
}
const span = tracer.startSpan('agent.log_event', undefined, context.active());
span.setAttributes({
'agent.session_id': sessionId,
'agent.event': evt.event || 'log',
'agent.tool': evt.tool || 'unknown',
});
span.end();
logger.emit({
body: line,
attributes: {
'agent.session_id': sessionId,
'agent.tool': evt.tool || 'unknown',
'agent.level': evt.level || 'info',
},
});
}
function inferSessionIdFromPath(path: string): string {
return path.split('/').pop()?.replace('.out', '') ?? 'unknown';
}
Now every worker log line becomes:
Maxxwell already tracks per-session state (working, idle, waiting on you, etc.). You want that state in your metrics backend.
Assume you have some way to dump session state as JSON (a shell helper you write, or whatever your launcher exposes) that prints something like:
[
{"id":"feature-123","tool":"claude-code","state":"working"},
{"id":"bug-456","tool":"codex","state":"waiting_on_you"}
]
You can scrape this every N seconds from the sidecar and update gauges.
Add to log-watcher.ts or a new module:
import { exec } from 'child_process';
const sessionStateGauge = meter.createUpDownCounter('agent_session_state', {
description: 'Session state encoded as integer per session',
});
const STATE_MAP: Record<string, number> = {
working: 1,
idle: 0,
waiting_on_you: 2,
blocked: 3,
done: 4,
dead: 5,
};
export function startSessionPoller() {
setInterval(() => {
exec('./dump-sessions.sh', (err, stdout) => {
if (err) return;
try {
const sessions = JSON.parse(stdout);
sessions.forEach((s: any) => {
const v = STATE_MAP[s.state] ?? -1;
sessionStateGauge.add(v, {
sessionId: s.id,
tool: s.tool,
state: s.state,
});
});
} catch {}
});
}, 5000);
}
Call startSessionPoller() from main().
Your metrics backend can now graph:
waiting_on_you (good proxy for “human is the bottleneck”).dead or blocked.This is where Maxxwell pays off compared to DIY tmux: states are first-class, not inferred from log silence.
The orchestrator seat in Maxxwell is itself a coding-agent session started from a brief. You talk to it instead of a dozen terminals; it fans work out to workers.
You want one root span per workflow (per orchestrator brief) and child spans per worker lane.
Pattern:
workflow_id (e.g. Git branch, ticket ID).workflow_started log line), start a root span.workflow_id in their config so logs can link back to the root span.Example span wiring in handleLogLine:
const workflowId = evt.workflowId;
if (evt.event === 'workflow_started') {
const span = tracer.startSpan('workflow', {
attributes: {
'workflow.id': workflowId,
'workflow.summary': evt.summary,
},
});
span.end();
}
if (evt.event === 'worker_assigned') {
const ctx = trace.setSpan(context.active(), tracer.startSpan('workflow.assign_worker', {
attributes: {
'workflow.id': workflowId,
'agent.session_id': sessionId,
'agent.tool': evt.tool,
},
}));
// later events for this session can use ctx to link to the workflow
}
In your tracing UI, you’ll see:
This is how you answer, “Why did this feature take three hours yesterday?” with evidence instead of Slack archaeology.
This view of workflow spans and session states over time makes it obvious which lanes are healthy, stalled, or waiting on human input.
Once telemetry flows, build a minimum viable dashboard. Most tools (Grafana, Honeycomb, Dynatrace) can do this in 15-30 minutes.
Start with:
agent_session_state by state.waiting_on_you sessions > N for more than M minutes.agent_tokens_total by tool and direction.agent_session_errors_total by tool.state = 'blocked' or 'dead'.This gives a tech lead one screen that answers:
From there, you can iterate into SLOs: “80% of workflows finish in < 10 minutes of agent time, 95% have no unreviewed errors,” etc.
A few hard-earned lessons from agent observability docs (Google, Microsoft, OTel) and production teams:
workflow.id, agent.session_id, agent.tool beats a zoo of sessId, agentId, worker.Maxxwell’s own posture helps: it never acts without you (fleet controls draft rather than act), and it exposes uncertain state as “not heard from” instead of “probably working”. Your telemetry should do the same - better gaps than lies.
Other multi-agent tools are doing interesting things here:
Maxxwell’s angle is different:
That makes Maxxwell a good fit for agent-native development where the control plane is explicit but the autopilot is still you.
Use the combination of:
agent_session_state (gauge) to see how long sessions stay in waiting_on_you, blocked, or possibly stalled.agent_tokens_total to see which sessions burn tokens without progressing state.Alert on patterns like “session in waiting_on_you > 15 minutes” or “workflow duration > 4x median”.
Yes. Maxxwell itself is local and doesn’t require any server.
For observability, you can:
That gives you a fleet health dashboard without shipping source.
Expose a dedicated metric:
agent_session_waiting_on_human{sessionId, tool} as a gauge (1 or 0).Populate it from Maxxwell’s session state poller whenever state === 'waiting_on_you'.
Then build a panel showing all such sessions, and an alert when their count or age crosses a threshold. This tells a lead where to focus attention.
In this Maxxwell setup:
You’ll usually:
No. Maxxwell conducts; it doesn’t autopilot.
It gives you:
You still own decisions like re-aiming a session that’s quietly building the wrong thing. Observability just makes those situations visible earlier.