Files
trace/README.md
Fredrik Johansson 31b2372ad3
All checks were successful
Docker / test-build-push (push) Successful in 37s
README: document actual usage, not just quickstart
Full walkthrough of the private incident notebook (timeline,
hypotheses, publishing), an endpoint reference table for the receipts
API, real curl examples for both receipt types, a config reference,
and an honest note about the dead pre-v1 /api/receipts endpoint
instead of leaving it undocumented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 03:44:46 +02:00

121 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# trace
Private incident notebook, Git fix-candidate inbox, deployment-receipt evidence store, and reviewed Astro Markdown exporter for goonk's `/failures` page. One ~70-line HTTP server (`src/server.mjs`), Node's built-in `node:sqlite`, no framework, no runtime dependencies.
```bash
cp .env.example .env # set TRACE_PASSWORD and TRACE_RECEIPT_TOKENS at minimum
npm start
```
Node 24+. `npm run dev` for `--watch` during local work; `npm test` runs the receipt-contract tests; `npm run build` is a syntax check (`node --check`), not a bundle — there's nothing to bundle.
## What trace actually is
Two things that get conflated everywhere else, kept structurally separate on purpose:
1. **A private incident notebook.** Record what broke, what you tried, what was actually true, and what you'd do differently — while you're still in it, not reconstructed from memory afterward. Nothing here is public until a human explicitly publishes it.
2. **An operational receipts ledger.** CI says what it built; a deploy host says what it actually started and verified. Trace stores both as separate, versioned, authenticated event types and never lets a green build masquerade as a live deployment.
The two features share a database and a process, but not a purpose — the incident notebook is where a human writes a story, the receipts ledger is where machines report bare facts.
## The private incident notebook
Everything under `/` (Basic Auth, `trace:$TRACE_PASSWORD`) is private by default:
- **`/`** — every incident, newest first, with a live status breakdown.
- **`/incidents/new`** → **`/incidents/:id`** — title, project, status (`investigating` / `mitigated` / `resolved` / `abandoned`), severity (`annoyance``security-risk`), and the fields that actually make a postmortem useful: symptom, impact, root cause, confidence, fix, verification, prevention, remaining risk, lesson. Confidence is tracked separately from status on purpose — "resolved, high confidence" and "resolved, we're not totally sure why" are different claims and shouldn't collapse into one field.
- **Timeline** — append-only entries per incident, each tagged `observation` / `hypothesis` / `action` / `result` / `decision` / `deploy` / `note`. This is the actual investigation log, in order, not reconstructed after the fact.
- **Hypotheses** — a statement, a status (`untested` / `supported` / `rejected` / `inconclusive`), and evidence. Explicitly tracking rejected hypotheses is the point — "we thought it was X, it wasn't, here's how we knew" is worth as much as the real answer.
- **`/candidates`** (the Git inbox) — click "scan configured repositories" to pull commits whose subject line matches fix-shaped keywords (`fix`, `bug`, `broken`, `race`, `crash`, `regression`, `rollback`, `restore`, `duplicate`, `auth`, `fail`) from either locally mounted read-only repo mirrors (`TRACE_REPOS`) or Gitea's API with no checkout at all (`TRACE_GITEA_URL` + `TRACE_GITEA_REPOS`). Link a candidate to an incident's UUID to connect "here's the bug" with "here's the commit that caused or fixed it."
### Publishing a real postmortem
An incident page has an **export public draft** action (writes reviewed Markdown to `data/exports/` for hand-editing) and, once you're happy with it, **publish / update reviewed article** — this sets a public slug and makes the incident visible through the read-only public API below. **Unpublish** reverses it instantly. Nothing crosses from private to public without that explicit action; a `symptom` field with an internal hostname in it never accidentally becomes a blog post.
```bash
curl -u trace:$TRACE_PASSWORD -X POST http://localhost:3082/incidents/new \
--data-urlencode "title=under-the-hood showed a stale deploy commit" \
--data-urlencode "project=goonk" \
--data-urlencode "detected_at=2026-07-21T01:00" \
--data-urlencode "status=resolved" \
--data-urlencode "severity=annoyance" \
--data-urlencode "symptom=Deployed showed an older commit and earlier timestamp than Built." \
--data-urlencode "root_cause=Nothing on the deploy host ever submitted a deployment receipt." \
--data-urlencode "fix=Added best-effort receipt submission to the shared deploy wrapper." \
--data-urlencode "public_summary=A deploy dashboard kept showing an old commit as current."
# → 303 redirect to /incidents/<new-uuid>
```
The form works the same from a browser; this is just the same POST scripted, useful for backfilling incidents from notes taken elsewhere.
## Deployment receipts (v1)
Two separate authenticated write paths, one shared authenticated read-only view:
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
| `/api/v1/events` | `POST` | `Authorization: Bearer <project-scoped token>` | Submit a build/deployment/release/rollback receipt |
| `/events` | `GET` | Basic (`trace:$TRACE_PASSWORD`) | Private, human-readable feed of every received event, raw payload included |
| `/api/v1/public/projects/:project/latest` | `GET` | none | Latest **deployment** event only, allowlisted fields |
| `/api/v1/public/projects` | `GET` | none | Latest deployment per project, same allowlisted shape |
| `/api/health` | `GET` | none | Liveness probe (used by the Dockerfile's `HEALTHCHECK`) |
A receipt (`schemaVersion: 1`) needs `eventType` (`build`/`deployment`/`release`/`rollback`), `eventId`, `project`, `occurredAt` (ISO-8601), `result` (`success`/`failed`/`cancelled`/`unknown`), and `source.commit` (a 764 char hex SHA). `eventId` is the idempotency key — resubmitting the same one returns `{"ok":true,"duplicate":true}` rather than a second row, which is what makes "retry the CI step that failed to reach trace" safe.
```bash
# CI, right after pushing images (a build receipt)
curl -H "Authorization: Bearer $TRACE_RECEIPT_TOKEN" -H "Content-Type: application/json" \
--data-binary '{"schemaVersion":1,"eventType":"build","eventId":"goonk-build-713-3777586","project":"goonk",
"occurredAt":"2026-07-21T03:06:00Z","result":"success",
"source":{"commit":"37775868ab303f9f8a7da09b354dd0cc31bb0c6f","branch":"main"},
"artifacts":[{"component":"web","digest":"sha256:fe9e15f73804..."}],
"verification":{"build":"success"}}' \
https://trace.dev.xplwd.com/api/v1/events
# A deploy host, after docker compose up -d and a real health check
curl -H "Authorization: Bearer $TRACE_RECEIPT_TOKEN" -H "Content-Type: application/json" \
--data-binary '{"schemaVersion":1,"eventType":"deployment","eventId":"goonk-production-3777586-20260721T013412Z",
"project":"goonk","environment":"production","occurredAt":"2026-07-21T01:34:12Z","result":"success",
"source":{"commit":"37775868ab303f9f8a7da09b354dd0cc31bb0c6f","branch":"main"},
"verification":{"health":"healthy"}}' \
https://trace.dev.xplwd.com/api/v1/events
# Check what a public consumer sees
curl https://trace.dev.xplwd.com/api/v1/public/projects/goonk/latest
```
Full producer onboarding — registering a project, per-project tokens, the exact CI step shape, and the deploy-host responsibilities above — is in [`docs/INTEGRATING_PROJECTS.md`](docs/INTEGRATING_PROJECTS.md). `keep/scripts/deploy.sh` is the reference deploy-host implementation of the second `curl` above: after `docker compose up -d`, it polls the app's own embedded build identity until healthy, then submits exactly this receipt — opt-in per project via a `.trace-meta` marker file, never assumed.
### A known rough edge
`POST /api/receipts` (Basic Auth, no `/v1`, no bearer token) still exists in `src/server.mjs` as a leftover from before the v1 events API was built. It writes to a `receipts` table nothing else ever reads from — dead code, not a second real API. Use `/api/v1/events` for everything; the old path is scheduled for removal, not a documented feature.
## Configuration reference
| Variable | Required | Purpose |
|---|---|---|
| `PORT` | no (default `3082`) | HTTP listen port |
| `TRACE_PASSWORD` | effectively yes | Basic Auth password for `/`, `/events`, and the publish/unpublish actions. Empty disables auth entirely — fine for a throwaway local instance, never for anything reachable outside localhost |
| `TRACE_RECEIPT_TOKENS` | yes, for receipt ingestion | `project:token,project2:token2` — one random token per producer (`openssl rand -hex 32`), never shared across projects |
| `TRACE_REPOS` | no | `id:/absolute/path,id2:/absolute/path2` — locally mounted **read-only** repo mirrors for the Git inbox |
| `TRACE_GITEA_URL` | no (default `https://repo.explewd.com`) | Base URL for Gitea-API-based candidate scanning, no checkout needed |
| `TRACE_GITEA_REPOS` | no | Comma-separated `owner/repo` list to scan via the Gitea API |
| `TRACE_GITEA_TOKEN` | no | Only needed for private Gitea repositories; public ones scan with no token |
## Deploying
```bash
cp .env.example .env # fill in real values
docker compose up -d
```
Single service, a named volume for `data/` (SQLite + exports), a `HEALTHCHECK` hitting `/api/health`. CI (`.gitea/workflows/docker.yml`) runs `npm test` and `npm run build` before it will push an image — a broken receipt-validation contract or a syntax error never reaches the registry.
## Security boundaries
- One bearer token per project; a `retur` token cannot submit a `goonk` receipt (checked with `crypto.timingSafeEqual`, not `===`).
- `TRACE_PASSWORD` gates every private route in one place — the notebook, the raw events feed, and publish/unpublish.
- The public API (`/api/v1/public/*`) never includes raw payloads, tokens, internal hostnames, or full registry references — only the allowlisted fields in `publicEvent()`/`publicIncident()`.
- `TRACE_REPOS` should be empty in any deployment with no deliberate read-only mirrors mounted; receipt ingestion never requires repository access at all.
- Trace being unreachable should never break a producer's build or deploy — every documented integration point treats submission as best-effort.