Conductor exposes Prometheus metrics and writes structured logs. All configuration lives under the observability key in conductor.config.json.
Conductor starts an HTTP server serving Prometheus metrics at:
GET http://localhost:<metricsPort>/metrics
The server binds all interfaces (0.0.0.0). There is no authentication or TLS. All other paths return 404. The default port is 9090.
{
"observability": {
"metricsPort": 9090
}
}All metrics use a conductor_ prefix.
| Metric | Type | Labels |
|---|---|---|
conductor_sessions_total |
Counter | state, plugin_id |
conductor_sessions_active |
Gauge | state |
conductor_sessions_completed_total |
Counter | plugin_id |
conductor_sessions_reaped_total |
Counter | plugin_id, result |
conductor_idle_timeouts_fired_total |
Counter | plugin_id |
conductor_oldest_session_age_seconds |
Gauge | — |
state values: CREATED, ACTIVE, IDLE, APPROVAL, COMPLETE. plugin_id is the stable id from the plugin definition (conductor.reconciled for a session re-adopted after a restart without metadata).
conductor_sessions_total increments each time a session enters a state, so it always rises. conductor_sessions_active is the live count by state — {state="CREATED"} 3 with no ACTIVE shows three sessions wedged before their first signal. conductor_sessions_completed_total and conductor_sessions_reaped_total (result: ok/error) are the lifecycle counterweights to creation: created=3, completed=0 flags that reaping is broken. conductor_idle_timeouts_fired_total counts idle timers that actually fired (0 means the timer never armed). conductor_oldest_session_age_seconds is refreshed every sessionInventoryIntervalMs; a value far above idleTimeoutMs is itself an alertable condition.
| Metric | Type | Labels |
|---|---|---|
conductor_signals_received_total |
Counter | type, result |
type values: activity, stop (a stop:approval wire signal counts as stop). result values: ok (the signal drove a tracked session) / error (no such session, or an illegal transition). This is the single most useful gap-detector: conductor_sessions_total{state="CREATED"} rising while conductor_signals_received_total stays at 0 means the agent hooks are not reaching the daemon at all.
| Metric | Type | Labels |
|---|---|---|
conductor_plugin_init_duration_ms |
Histogram | plugin_id |
conductor_plugin_errors_total |
Counter | plugin_id, type |
Init duration buckets (ms): 10, 50, 100, 500, 1000, 5000, 10000, 30000. conductor_plugin_init_duration_ms records once per plugin whose init() resolves within the 30s timeout. conductor_plugin_errors_total type values: init (init threw) / init_timeout (init exceeded the timeout).
| Metric | Type | Labels |
|---|---|---|
conductor_scheduler_runs_total |
Counter | plugin_id, job_type |
conductor_scheduler_run_duration_ms |
Histogram | plugin_id, job_type |
job_type is interval (from scheduler.interval()) or schedule (from the daily scheduler.schedule()). Both metrics record once per job invocation, whether the job resolved or threw. Duration buckets (ms): 10, 50, 100, 500, 1000, 5000, 30000, 60000.
| Metric | Type | Labels |
|---|---|---|
conductor_ipc_events_total |
Counter | signal |
signal values: activity, stop, stop:approval. It increments once per IPC event drained from disk, before the event is applied to a session. (conductor_signals_received_total above adds the apply result and collapses stop:approval into stop.)
| Metric | Type | Labels |
|---|---|---|
conductor_concurrency_active |
Gauge | scope |
conductor_concurrency_waiting |
Gauge | scope |
scope is "global" for the daemon-wide limit. Both gauges are mirrored from the limiter on every acquire/release, so they reflect live state.
| Metric | Type | Labels |
|---|---|---|
conductor_secrets_resolution_total |
Counter | backend, result |
Recorded once per secrets.get(). backend is the source that satisfied the lookup — env, gh-cli, keychain, prompt — paired with result="ok", or unresolved with result="error" when no source produced a value.
Plugins can register and emit their own custom metrics through the metrics handle on the PluginContext. Plugin metrics render at the same /metrics endpoint as core metrics — no extra wiring required.
Every plugin-registered metric name is automatically prefixed with:
conductor_plugin_<sanitized_plugin_id>_
The plugin id is baked into the metric name (not just a label), so two plugins can never collide — even if they declare the same metric name with different label sets (which would otherwise throw at registration in prom-client). This follows Prometheus' own subsystem-prefix convention (go_, process_, nodejs_).
plugin "acme.deploybot" -> conductor_plugin_acme_deploybot_deploys_total
plugin "conductor.builtin.github-issues" -> conductor_plugin_conductor_builtin_github_issues_polls_total
Sanitization — both the plugin id and the metric name are normalized to valid Prometheus name tokens ([a-zA-Z0-9_]): any run of disallowed characters collapses to a single _, and leading/trailing _ are trimmed. Because the fixed prefix is always prepended, a plugin cannot escape its namespace via a crafted name (e.g. name: "../core" → conductor_plugin_<id>_core). A name that is empty after sanitization is rejected.
The metrics handle returns real prom-client instances, so plugins get the full familiar API (.inc(), .observe(), .startTimer(), .labels()):
export interface PluginMetricOptions {
name: string; // namespaced automatically; do NOT include the conductor_plugin_ prefix
help: string;
labelNames?: string[]; // plugin-defined dimensions; the prefix handles isolation
}
export interface PluginHistogramOptions extends PluginMetricOptions {
buckets?: number[];
}
export interface PluginMetrics {
counter(opts: PluginMetricOptions): Counter<string>;
gauge(opts: PluginMetricOptions): Gauge<string>;
histogram(opts: PluginHistogramOptions): Histogram<string>;
}Registration is idempotent: calling counter()/gauge()/histogram() again with the same name returns the existing instance instead of throwing. Re-registering a name under a different metric type throws a clear error. When a plugin is unloaded its metrics are removed from the registry (counters reset on reload).
async init({ metrics, scheduler, logger }) {
const polls = metrics.counter({ name: "polls_total", help: "Poll attempts", labelNames: ["result"] });
const pollMs = metrics.histogram({ name: "poll_duration_ms", help: "Poll duration", buckets: [50, 100, 500, 1000, 5000] });
scheduler.interval(pollIntervalMs, async () => {
const end = pollMs.startTimer();
try { await poll(); polls.inc({ result: "ok" }); }
catch { polls.inc({ result: "error" }); }
finally { end(); }
});
}The built-in github-issues plugin ships these metrics as a reference implementation. Names below are shown without the conductor_plugin_conductor_builtin_github_issues_ prefix.
| Metric | Type | Labels |
|---|---|---|
polls_total |
Counter | result (ok/error) |
poll_duration_ms |
Histogram | — |
issues_seen_total |
Counter | — |
sessions_created_total |
Counter | result (ok/error) |
rate_limited_total |
Counter | — |
label_updates_total |
Counter | label, result (ok/error) |
open_sessions |
Gauge | — |
{
"observability": {
"logPath": "~/.local/dotlogs/conductor.log",
"logMaxBytes": 10485760,
"logMaxBackups": 5,
"logFormat": "json",
"logCaller": false,
"sessionInventoryIntervalMs": 120000,
"signalStallWarnMs": 300000
}
}| Option | Type | Default | Description |
|---|---|---|---|
logPath |
string |
~/.local/dotlogs/conductor.log |
Log file path. ~ is expanded to $HOME. |
logMaxBytes |
number |
10485760 |
File size in bytes that triggers rotation (10 MiB). |
logMaxBackups |
number |
5 |
Number of rotated files to keep. Oldest is deleted when exceeded. |
logFormat |
"json" | "logfmt" |
"json" |
Wire format for the log file and non-TTY stderr. |
logCaller |
boolean |
false |
Attach a caller field with the file:line of the log call site. |
sessionInventoryIntervalMs |
number |
120000 |
How often the daemon logs a session inventory line and refreshes conductor_oldest_session_age_seconds (2 min). |
signalStallWarnMs |
number |
300000 |
How long a session may sit in CREATED without its first signal before a no signal received for session warning fires (5 min). |
When a log line would push the file past logMaxBytes, Conductor renames conductor.log → conductor.log.1, shifts existing backups up, and drops the oldest. The rotated files follow the pattern conductor.log.1 through conductor.log.<logMaxBackups>.
When stderr is a terminal, Conductor writes colored human-readable output to stderr. debug entries are suppressed from stderr and written to the file only. When stderr is not a TTY, each line is written to both the file and stderr in the configured format.
JSON (default) — one JSON object per line:
{"ts":"2026-06-05T12:00:00.000Z","level":"info","component":"conductor","msg":"Conductor started","pluginCount":2}
logfmt — space-separated key=value pairs; values with spaces or special characters are quoted:
ts=2026-06-05T12:00:00.000Z level=info component=conductor msg="Conductor started" pluginCount=2
Every log line includes:
| Field | Description |
|---|---|
ts |
UTC timestamp (ISO 8601) |
level |
debug, info, warn, or error |
component |
"conductor" for daemon logs; plugin name for plugin logs |
msg |
Human-readable message |
caller |
file:line — only present when logCaller: true |
Additional fields by source:
| Source | Fields |
|---|---|
| Daemon startup | pluginCount, dataDir |
| Signal invocation resolved | invocation |
| Session state change | sessionId, from, to, event |
| Session inventory (periodic) | count, sessions (id/name/state/ageSeconds per session) |
| Stalled session warning | sessionId, name, state, ageMinutes |
| Unknown / invalid signal | sessionId, event (and state, error for invalid) |
| Plugin loaded | pluginId, name |
| Plugin load error | path, error |
| Scheduler error | error, time (daily jobs only) |
The signal hooks conductor injects run inside the spawned agent (Claude Code) in a separate tmux window. If that invocation is wrong (for example a bare bun with no script path), the hook fails in the agent's pane and the failure never reaches the daemon log directly. The startup line below records the exact command, so a malformed invocation is visible without scraping a pane:
{"level":"info","msg":"signal invocation resolved","invocation":"/usr/local/bin/bun /path/to/src/index.ts"}
When signals never arrive, conductor still makes the failure loud: the periodic session inventory line shows sessions wedged in CREATED, the no signal received for session warning fires once per stalled session after signalStallWarnMs, and conductor_signals_received_total stays at 0 while conductor_sessions_total{state="CREATED"} climbs.
Plugin log lines use the plugin's name as the component field, making it straightforward to filter by plugin in any log tool that understands the JSON or logfmt format.