Cendor Monitor — watch your governed agents on your own machine

Cendor emits standard OpenTelemetry — one wire, no Cendor-specific exporter, never a Cendor endpoint. Where it goes is your choice: your own backend (Azure Monitor, CloudWatch, Datadog, Langfuse, any OTLP) for production fleets — or Cendor Monitor, a free, open-source, self-hosted journey view, when you want to see what your agents did and what each run cost in one screen: every prompt, token, dollar, and the exact step where a budget or guardrail acted. Same wire either way; switch or run both without touching code.

Two doors, and a window: the libraries and the SDK are how you build; Cendor Monitor is how you watch. It is optional dev tooling (like cendor-mcp) — no library depends on it, and your own OTel backend stays the documented production default. It runs on your infrastructure; Cendor never operates a telemetry endpoint.

Run it in 60 seconds

One image — the OTel Collector, a small Cendor ingest service, a dual-backend store (SQLite by default), and the monitor. Run it, point your app’s OpenTelemetry at it, and open the monitor:

# 1. run the monitor (one image; SQLite by default — nothing else to install)
docker run --rm --name cendor-monitor -p 3000:3000 -p 4317:4317 -p 4318:4318 ghcr.io/cendorhq/cendor-monitor:0.15.0

# 2. point your app's OpenTelemetry pipeline at it
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_SERVICE_NAME=my-app          # optional — names your app on the Apps page

# 3. open the monitor
#    http://localhost:3000

That is the whole integration — a standard OTLP env var, no Cendor API, key, or endpoint, and no Cendor telemetry code: with an OpenTelemetry provider configured in your app (which the env var above plus your usual SDK setup gives you), calls, run trees, spend and enforcement decisions all flow on their own. Sessions group runs automatically when you pass run(session=…), so there is no trace id to paste anywhere. Requires cendor-core ≥ 1.13 / @cendor/core ≥ 0.15 (+ cendor-sdk ≥ 1.19 / @cendor/sdk ≥ 0.22); on older versions attach the emitters explicitly, as Observability describes. Nothing arriving? CENDOR_DEBUG_TELEMETRY=1 prints one line telling you whether a provider was detected — and CENDOR_TELEMETRY=off is the way to stop sending, with no code change.

The monitor tour

The monitor is a self-contained view over the same standard wire — no query language, no dashboard to assemble. Screenshots are of the real monitor rendering one real governed run (a keyless, reproducible refunds-triage workload with content capture opted in for it) — the image itself ships no sample app and fabricates nothing: every screen you see is fed by your own runs, and an empty store just means nothing has been sent yet, not that something is broken. (The shots are of the current v0.14.2 console — the same tag the docker run above pulls — so the two doors, the Container page, the agent dimension and the verdicts-first governance stream are all visible in them.)

Overview

Land on the Overview: the Cendor value stripblocked projected spend (the projection a budget refused pre-flight — never spent), tokens saved by compression (Σ before − after, +%), replayed calls ($0) (a count, not an invented dollar-saving), and guardrail blocks — above the fleet tiles (runs, spend, blocks, errors, p50/p95, TTFT), dependency-free trend charts, and cost attribution: top models, top agents, top users by spend, and spend by feature. A time-range row (1h/24h/7d/30d/all) and a Go live toggle sit up top; every tile clicks through to its rows.

Cendor Monitor — Overview Cendor Monitor — Overview (light theme)

Agents → Sessions → Runs

Drill from an agent to its sessions to the runs inside them — the drill-down is built from the auto gen_ai.conversation.id your SDK stamps from run(session=…). A session’s rollup header + stitched conversation thread across its runs:

Cendor Monitor — a session's runs Cendor Monitor — a session's runs (light theme)

The run journey — with governance inline

The differentiator: the whole conversation for a run — system / user / assistant / thinking / tool arguments and results — with tokens, cost, latency, and time-to-first-token per step, and the exact step where a budget block, guardrail verdict, or compression fired, shown inline in the conversation (not in a separate log). Prompt/response content appears only if you opted in (below); without it, the journey shows the same structure metadata-only.

Governance events link back to the run that produced them — a governed run correlates its whole trail to the run’s trace (the run root is the active span; automatic since cendor-sdk 1.19 / @cendor/sdk 0.22, or inside an explicit live_spans() / liveSpans with sdk ≥ 1.12 / 0.17), and a run built post-hoc from span_tree still links by run id. So a run’s governance count is never a dead end: open the run and every verdict sits on the step it governed.

You get governance without writing any governance code. A budget block or guardrail verdict arrives as a governance.* span (cendor.gov.* attributes) and renders in the same Governance board and the same inline run verdicts. If you do keep an AuditLog, its mirrored audit.* entries take over (richer: chained, hashed, sequenced) and the ops spans stand down, so nothing is shown twice. Either way the board is an operational copy — the hash-chained file on your host is the only thing verify() checks.

Cendor Monitor — run journey with governance inline Cendor Monitor — run journey with governance inline (light theme)

Two doors: SDK runs and libs-only calls (v0.9 — two stores, two modes)

The monitor renders two doors, because the two ways to use Cendor put different amounts of identity on the wire — the truth is identical, the shape isn’t. A run under cendor-sdk (live_spans() / span_tree(), scope cendor.sdk) carries an agent.run root, so it gets the full run ▸ session ▸ agent hierarchy. A libs-only app (you called instrument() and use_span_emitter() yourself, scope cendor.core) emits flat governed calls with no run root — tokens, cost, and governance are all there (the row rolls them up from its own steps), but there is no session/agent hierarchy, by design. The split adds no wire attribute — it reads the OTel instrumentation-scope name already on every span. Same wire, richer identity: an sdk run is never “more governed” than a libs call.

v0.9 makes the split first-class. Each door is its own store (/data/libs.db + /data/sdk.db, or point either at Postgres — mixed mode is supported), and the console has two full UX modes with the door in the URL (/libs/…, /sdk/… — real routes, so two browser tabs can watch two doors at once). A persistent Libraries | SDK switcher swaps the whole surface; single-door installs never see it. The Libraries mode is Overview · Calls · Governance · Models + the seven proof pages (no agent/session vocabulary). The SDK mode adds Apps · Agents · Sessions · Runs and a Structure group — Orchestration (a per-run agent-handoff graph + a fleet flow map), Tools (per-tool invocations, error/block rate, local vs mcp), MCP (servers seen + per-server attribution), RAG, Memory, and Checkpoints — all rendered from the structural cendor.sdk spans the SDK emits (cendor-sdk ≥ 1.16 / @cendor/sdk ≥ 0.21). A slim status footer shows the honest ingest truth (“listening :4318 · last span 12s ago”, never “app connected”), each door’s store backend + size, and the image version. Go Live streams updates over Server-Sent Events (live steps, not live tokens); it falls back to polling automatically. Fresh v0.9 stores start empty — there is no migration from a pre-v0.9 single store (a dev-phase breaking change; the old volume is left untouched).

The Libraries door in v0.12.3–v0.14.0 — call groups, the agent dimension, and an honest money table

Four measured gaps closed, all on the door that had the least identity to work with:

  • A call group is real now. With cendor-core ≥ 1.14 / @cendor/core ≥ 0.16, core.trace("id") opens a real parent span, so a scope over N calls arrives as one row with STEPS = N — the scope id leads the Calls list (a group chip, the models beside it) and the detail heading reads Run instead of Call. Before those versions the scope only stamped an id, so the same code produced N unrelated rows; the console’s own advice pointed at a lever that could not move. It is still a call group, not a session: the Libraries door gains no agent-or-session hierarchy from it.
  • The agent dimension appears when your framework provided one. Cendor ships three framework adapters whose whole purpose is to attach an agent name to a libs-door call, and the console used to answer “the Libraries door has no agents” even when the store held one. There is now an Agents (from your framework) page, an Agent column and an agent filter — shown only when the store actually contains an agent, with the provenance stated on the page. A plain libs app still sees no agent vocabulary, and Sessions stays SDK-only.
  • gen_ai.agent.id, when you have one. The Agents page shows the id as a chip beside the name, so two agents that share a name across apps no longer collapse into one row and a rename no longer loses that agent’s history. Absent, nothing is shown — never a hash, never a placeholder.
  • The cost-attribution caveat. A spend metric datapoint carries no run scope on the wire, so both stores receive every datapoint and both doors show the same fleet-wide breakdown. Measured at one instant: the libs attribution table totalled $0.0257 against a libs run rollup of $0.0628 — neither the door’s spend nor a subset of it. That limit is real and cannot be fixed by splitting the table; what was missing was the console saying so, which it now does under every spend list. Per-door spend is the run rollup in the tiles above it.

Governance you can read (v0.13.0)

The audit stream carries two different things and nine spellings of about five outcomes. Both are now addressed on the Governance page:

  • One word per outcome. action_class=block|break|clamp|flag|redact folds the raw values, because the libraries name one concept differently per event type (budget_event.blocked vs guardrail_decision.block; policy_flag.redacted vs redact) — so a single-select raw action= could never answer “show me the blocks”. human_oversight.rejected sits in block (a human stopping an action stops it), and broken — a mid-stream on_exceed="break" — is its own outcome because the call did run. The raw action= filter is still there for power users.
  • Verdicts before trajectory. llm_call / tool_call are audit trajectory entries, not verdicts, and they made up 62–71 % of a measured stream. The page defaults to Decisions; Trajectory is one click away, and each tab shows the other’s count.
  • One decision, one row. decision + decision_record + decision_end collapse by decision_id.
  • A blocked run looks like the product working. A zero-step run with a blocking verdict reads blocked before any call; one without is muted no steps recorded. 11 of 27 measured zero-step runs were pre-flight blocks and looked exactly like noise.
  • The mirror-completeness hint stops crying wolf. seq is the audit file’s counter, shared by every run appended to that chain — and one chain per process is the recommended shape — so it warns only when a run’s own range is internally gapped, all its events share one audit system, and the run is that chain’s first.

Apps — the libs door’s top level (v0.8)

The libs door gets a top-level entity of its own, symmetric with the SDK door’s Agents page: the Apps page groups every run by its OTel resource service.name. Each app card rolls up runs, tokens, spend, governance, errors, and a distinct-instance count (from service.instance.id, the standard OTel per-process id) — click through to that app’s runs. This answers the “what contains all of a libs-only app’s runs?” question in-standard: an app’s identity is its own OTel resource, not anything Cendor invents. Set it with one env var and zero code:

OTEL_SERVICE_NAME=billing-bot   # (and optionally OTEL_RESOURCE_ATTRIBUTES=service.instance.id=…)

Runs with no service.name group under an unnamed app card that teaches exactly this fix. Nothing new on the wire — service.name was already stored on every run row; v0.8 makes it an entity and adds the nullable instance_id column (an additive, zero-loss schema upgrade).

Proof pages + the governance stream

A per-library proof page for each of the seven libraries turns the monitor into proof of the libraries — each with a trend chart, a value tile, and the raw event table. tokenguard’s page shows spend attributed by feature and by user plus a blocked-projected-spend tile and the budget-events table (which budget blocked what — projected vs cap); squeeze’s shows compression events with before/after tokens. The Governance page is a typed, filterable stream of every decision, budget event, and guardrail action.

Cendor Monitor — tokenguard proof page (spend by feature / user) Cendor Monitor — tokenguard proof page (light theme) Cendor Monitor — governance stream Cendor Monitor — governance stream (light theme)

Filter, search, and go live

The Runs, Sessions, and Governance lists share a filter bar — facet dropdowns (agent / model / service / label or type), a free-text box, a time-range preset, sort, and paging, all deep-linked in the URL. The free-text box has a scope toggle: meta filters the metadata, content runs a real indexed full-text search — but only over what you opted to store (nothing when content capture is off). A live toggle (off by default) polls every few seconds and pauses on a hidden tab.

(The screenshots above follow your theme — dark or light. Cendor Monitor ships both.)

Turn on content capture (opt-in)

By default the monitor shows structure — models, tokens, cost, latency, governance verdicts — but not message content. Prompts, responses, thinking, and tool values are captured only if you turn them on, in your app, with one call (or the standard env var). The monitor never enables it for you.

from cendor.core import otel

# Opt in. A mask scrubs each message list before export (fail-closed if it raises);
# max_bytes caps each attribute (a truncation marker is appended when hit).
otel.capture_content(mask=lambda msgs: msgs, max_bytes=8192)

# Or set the standard env var instead of code config:
#   OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
import { otel } from '@cendor/core';

// Opt in. `mask` scrubs each message list before export (fail-closed if it throws);
// `maxBytes` caps each attribute (a truncation marker is appended when hit).
otel.captureContent({ mask: (msgs) => msgs, maxBytes: 8192 });

// Or set the standard env var instead of code:
//   OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true

Where content lands: your store volume only (or your Postgres). In gateway mode (below), the container strips content attributes by default when forwarding upstream — you opt in again with CENDOR_MONITOR_FORWARD_CONTENT=true. Content never enters the acttrace evidence chain or its OTelMirroraudit.* stays content-free, and you delete captured content on your own retention schedule. See content capture.

Configuration

The container is configured entirely through environment variables — no config files to mount. The monitor reads a safe subset at boot (default theme, retention, storage backend type, whether forward / auth are on) — never the Postgres DSN, the auth password, or the forward URL. The Settings page shows the effective config and builds the exact docker run / compose command to change it:

Cendor Monitor — Settings (config reference + command builder) Cendor Monitor — Settings (light theme)
VariableDefaultWhat it does
Since v0.10.0 the two doors are configured independently: anything that governs a door’s data or
its view exists once per door (…_LIBS / …_SDK, no container-wide fallback) and appears only on
that door’s Settings page; the handful of settings that govern the container itself live on the
door-less Container page. Pre-v0.10.0 container-wide names were removed — the container maps a legacy
value forward onto both doors and logs a deprecation warning, so nothing changes silently on upgrade.
VariableDefaultWhat it does
OTEL_EXPORTER_OTLP_ENDPOINTSet in your app, not the containerhttp://localhost:4318. The whole integration.
CENDOR_MONITOR_DB_LIBS / _DB_SDK(unset → embedded SQLite)Per door. External Postgres DSN (postgres://…, ≥ 14) for that door; unset ⇒ embedded SQLite (WAL) on /data. Mixed mode (one door Postgres, one SQLite) is first-class. One image; Postgres is never bundled. Read from env only, never logged.
CENDOR_MONITOR_RETENTION_LIBS / _SDK7d eachPer door. An ingest-side sweeper deletes that door’s runs/steps/governance/metrics older than its own window (both backends).
CENDOR_MONITOR_RETENTION_CONTENT_LIBS / _SDK(unset)Per door. Optional shorter window for the opt-in content columns only — drops that door’s prompts/responses sooner than its metadata, which lives its full term.
CENDOR_MONITOR_MAX_SIZE_LIBS / _SDK(unset)Per door. Size cap (e.g. 500m, 2g); the sweeper evicts that door’s oldest runs first when its store exceeds it.
CENDOR_MONITOR_ALLOW_DELETE_LIBS / _SDKfalse eachPer door. Opt-in in-UI delete buttons for that door, backed by env-gated POST endpoints. Off ⇒ the read API stays GET-only. Opting one door in never opens the other.
CENDOR_MONITOR_THEME_LIBS / _SDKdark eachPer door. That door’s default theme (dark|light); a ?theme= query or the viewer’s saved toggle always overrides.
CENDOR_MONITOR_FORWARD_ENDPOINT(unset)Container-level. Gateway mode: the collector additionally relays all OTLP onward — the same image doubles as a prod gateway in front of your own backend. It runs before the ingest assigns a door, so it is not a per-door setting.
CENDOR_MONITOR_FORWARD_CONTENTfalseContainer-level. When forwarding, whether to include content attributes. Default strips them — content stays in your store only.
CENDOR_MONITOR_BASIC_AUTH(unset)Container-level. Optional user:password — HTTP basic auth over the whole UI + API. One credential per container. No auth by default (localhost dev tool — do not expose publicly).
CENDOR_MONITOR_THEMEdarkContainer-level. Theme of the door-less pages (the landing and the Container page).

Ports: 3000 = the monitor + /api/cendor/ (same origin via nginx); 4317 = OTLP/gRPC ingest; 4318 = OTLP/HTTP ingest. The monitor always listens on 3000 inside the container — remap the host port with -p 8080:3000. The read API is GET-only; delete-by-run/session runs ingest-side (plus the retention sweeper).

How it works

%%{init: {"flowchart": {"htmlLabels": false}} }%%
graph LR
    APP["your app<br/>(OTEL_EXPORTER_OTLP_ENDPOINT)"]
    COL["OTel Collector<br/>:4317 / :4318"]
    ING["Cendor ingest<br/>(Node, Apache-2.0)"]
    STORE["store<br/>SQLite (default) | your Postgres"]
    CON["Cendor Monitor<br/>:3000 · GET-only /api/cendor/"]
    FWD["gateway forward<br/>(content stripped by default)"]
    BACKEND["your own backend<br/>(optional)"]
    FILE["audit.jsonl<br/>on your app host = evidence"]

    APP -->|standard OTLP| COL
    COL --> ING --> STORE --> CON
    COL -.->|"if FORWARD_ENDPOINT set"| FWD --> BACKEND
    APP -.->|"acttrace file, separate path"| FILE

    classDef ev fill:#2563EB,color:#fff,stroke:#1D4ED8;
    class CON ev;

Everything runs where you run the container. The tamper-evident audit file is a separate, offline-verifiable path — it does not flow through the monitor (see Honest limits).

Plugs into the stack

Cendor Monitor consumes the exact wire the libraries and SDK already emit — the same Observability emitters (live_spans/span_tree, OTelSink, OTelMirror) and the same gen_ai.* GenAI semantic conventions. Because it is standards-native, the same opted-in wire renders in Langfuse or Braintrust too — nothing here is Cendor-proprietary. Each library’s data shows up on its proof page: tokenguard budgets and spend, guardrails decisions, acttrace’s mirrored governance, squeeze’s compression events, cassette’s replayed steps, contextkit’s assemblies, and core’s call spans. See each library’s page for what it emits — e.g. tokenguard and acttrace.

Operate it

Cendor Monitor is production-quality engineering in one self-hosted container — but it is dev/team tooling on your infrastructure, not a hosted service. The essentials (the full operations runbook ships inside the image, and is summarised at cendor.ai/monitor):

  • Backup/restore — SQLite: stop → tar the /data volume (WAL is checkpointed on a clean stop); Postgres: pg_dump / pg_restore on the database you own.
  • Upgradedocker pull the new tag and re-run reusing the same volume; the schema upgrades in place (a schema_version row; additive migrations; zero data loss, tested on both backends).
  • Exposing it beyond localhost — do all three: set CENDOR_MONITOR_BASIC_AUTH, front it with TLS (a reverse proxy — Caddy/nginx/Traefik), and add SSO via forward-auth (oauth2-proxy / Cloudflare Access / Authelia — no monitor-side code). The OTLP ports (4317/4318) have no auth, ever — keep them on a private network your apps share; never publish them to the internet.
  • Retention — per door: CENDOR_MONITOR_RETENTION_{LIBS,SDK} (metadata) + the optional shorter CENDOR_MONITOR_RETENTION_CONTENT_{LIBS,SDK} (that door’s content dropped sooner). Each door’s Settings page previews its own next sweep.
  • Scale envelope — designed for dev/team scale and measured to 100k runs / 500k steps (~125–162 MiB): the paginated runs list + content search stay interactive (single-digit-to-~20 ms); whole-fleet all-time aggregates land ~0.5–1.5 s (windowed queries are far faster; a live store under retention is much smaller). Full numbers in the repo’s review/perf-v05.md.
  • Supply chain — each tagged build runs a Trivy scan (fails on fixable CRITICAL) and ships an SBOM (syft, SPDX JSON); a weekly rebuild refreshes :latest against upstream CVE fixes.

Honest limits

  • Optional dev tooling, not the product. No Cendor library depends on it; the libraries and SDK run fully offline without it. Your own OTel backend (Azure Monitor / CloudWatch / Datadog / any OTLP) stays the documented production default.
  • Never a hosted service. Cendor never operates a telemetry endpoint. The container runs on your infrastructure; data lives on your volume or in your Postgres, and you delete it on your own retention schedule.
  • The monitor is an operational copy — not the evidence. verify() runs on the hash-chained audit file on your app host, never on what the monitor shows. The monitor has no “verified ✓” claim and makes no compliance guarantee; treat it as monitoring, treat the file (or a signed export() pack) as the record. See acttrace Honest limits.
  • Content is opt-in and off by default. The monitor never enables content capture; without it, runs render metadata-only. When on, content lands only where your OTLP goes.
  • Dev-tool scale. SQLite by default suits a single builder’s dev loop; point CENDOR_MONITOR_DB_{LIBS,SDK} at your own Postgres for a shared team deploy. No auth by default — set CENDOR_MONITOR_BASIC_AUTH and don’t expose it publicly.
  • Licensing — a permissive aggregate, no AGPL/GPL. See Licensing below.

Licensing

The image is a permissive aggregate: Apache-2.0 (code) with OFL-1.1 fonts. Every part is under a permissive license — no AGPL/GPL anywhere, so running the container triggers no copyleft or source-disclosure obligation (the kind a legal/security review usually screens for).

ComponentLicenseNotes
Cendor’s monitor UI, ingest service, and collector configs (the parts Cendor wrote)Apache-2.0Use / modify / redistribute / commercial; keep the NOTICE, state changes; includes a patent grant.
OpenTelemetry Collector (bundled binary — the OTLP front door)Apache-2.0Same permissive terms; keep its notice.
Node.js (runs the ingest service)MITPermissive, minimal — keep the copyright line.
nginx (the :3000 front door)BSD-2-ClausePermissive, minimal — keep the copyright line.
Manrope + JetBrains Mono (vendored fonts)SIL OFL-1.1Permissive for bundling. Two caveats: the fonts may not be sold on their own, and a modified font must be renamed (Reserved Font Name). This is why the aggregate isn’t “pure Apache-2.0”.

Obligations are light — keep the bundled notices, shipped in the image at /licenses (with the OFL font texts under /licenses/fonts). Nothing here forces you to disclose your own source. Trademarks belong to their owners: bundling OpenTelemetry / Node.js / nginx / JetBrains software under their OSS copyright licenses grants no trademark rights (names, logos), and implies no endorsement.

The license of the image (Apache-2.0 + OFL) is independent of where the source is hosted — “Apache-2.0 (code)” describes the code’s terms, not its public availability.