#!/usr/bin/env tsx /** * @decocms/start — observability config audit * * Read-only auditor for a site's `wrangler.jsonc`. Detects drift away * from the canonical Cloudflare-native observability block documented * in `docs/observability.md`. CI-friendly: exits 0 on a clean audit, 1 * on findings. * * This is the **detect** half of D3 ("audit is the safety net"). The * matching **fix** half is `migrate-to-cf-observability.ts`, which can * rewrite the block back to canonical with `--write`. Every rule here * has a corresponding behavior in the codemod — there is no rule we * can detect but not auto-fix. * * Rules (id — severity — what it catches): * * observability_missing error No `observability` key at all. CF captures nothing. * observability_disabled error `observability.enabled: false`. Master switch off. * traces_disabled warn `observability.traces.enabled: false`. No traces in dashboard. * logs_disabled warn `observability.logs.enabled: false`. No logs in dashboard. * head_sampling_rate_elevated error `traces.head_sampling_rate > 0.01`. Fleet-scale cost risk; see docs/observability.md. * logs_head_sampling_rate_low warn `logs.head_sampling_rate < 1`. Sampling info/warn logs loses signal cheaply; errors go via the direct-POST channel. * persist_disabled_no_destination error `persist: false` with no destination configured. Data captured then discarded. * * Phase 6 / D-14 — fleet-config drift rules (live outside the * `observability` block but still owned by this audit): * * version_metadata_binding_missing error Missing `version_metadata` binding. `service.version` won't be stamped — regressions can't be attributed to a deploy. * analytics_engine_binding_missing warn No `DECO_METRICS` AE binding. AE meter is off; OTLP meter still works but CF dashboard panels go dark. * tail_consumer_missing error No `tail_consumers` entry pointing at `deco-otel-tail`. 100% error-capture is broken. * otel_metrics_endpoint_missing warn `DECO_OTEL_METRICS_ENDPOINT` not set on `vars`. OTLP meter is off; only AE works. * otel_traces_endpoint_missing warn `DECO_OTEL_TRACES_ENDPOINT` not set on `vars`. Framework `deco.*` spans drop unless CF Traces is on. * otel_logs_endpoint_missing warn `DECO_OTEL_LOGS_ENDPOINT` not set on `vars`. Error logs ride CF Destinations only (head-sampled). * * Usage (from a site directory): * npx -p @decocms/start deco-audit-observability # audit cwd (warn mode — exit 0) * npx -p @decocms/start deco-audit-observability --source ./ # explicit source dir * npx -p @decocms/start deco-audit-observability --json # machine-readable * npx -p @decocms/start deco-audit-observability --mode block # error findings exit 1 (CI gate) * npx -p @decocms/start deco-audit-observability --github # GitHub Actions annotations * * Options: * --source Site directory (default: .) * --json Emit findings as JSON to stdout * --mode Gate hardness: "warn" (default — always exit 0 on findings, * just print them) or "block" (exit 1 on any `error` finding). * See D-16 in MIGRATION_TOOLING_PLAN.md for the rationale on * why warn is the v1 default. * --github Emit `::warning::` / `::error::` lines for GitHub Actions * annotations in addition to the normal text output. * --help, -h Show this message * * Exit codes: * 0 — no findings, or `--mode warn` (the default) regardless of findings * 1 — `--mode block` and at least one `error`-severity finding * 2 — file invalid / can't parse */ import * as fs from "node:fs"; import * as path from "node:path"; import { parseJsonc } from "./lib/jsonc"; export type Severity = "error" | "warn" | "info"; export interface Finding { id: string; severity: Severity; message: string; /** Suggested remediation — usually a codemod invocation. */ fix?: string; } export type GateMode = "warn" | "block"; interface CliOpts { source: string; json: boolean; help: boolean; mode: GateMode; github: boolean; } function parseArgs(argv: string[]): CliOpts { const opts: CliOpts = { source: ".", json: false, help: false, mode: "warn", github: false, }; for (let i = 0; i < argv.length; i++) { const flag = argv[i]; switch (flag) { case "--source": opts.source = argv[++i] ?? "."; break; case "--json": opts.json = true; break; case "--mode": { const value = argv[++i]; if (value !== "warn" && value !== "block") { console.error( `audit: --mode must be "warn" or "block" (got "${value ?? ""}")`, ); process.exit(2); } opts.mode = value; break; } case "--github": opts.github = true; break; case "--help": case "-h": opts.help = true; break; } } return opts; } function showHelp(): void { console.log(` @decocms/start — observability config audit Read-only check for drift from the canonical Cloudflare-native observability block in wrangler.jsonc. Pair with \`deco-cf-observability --write\` to auto-fix. Usage: npx -p @decocms/start deco-audit-observability [options] Options: --source Site directory (default: .) --json Emit findings as JSON --mode "warn" (default, exit 0) | "block" (exit 1 on errors) --github Emit ::warning::/::error:: lines for GitHub Actions --help, -h This message Exit codes: 0 no findings, OR --mode warn (default — annotate and move on) 1 --mode block AND at least one error-severity finding 2 wrangler.jsonc missing or unparseable See D-16 in MIGRATION_TOOLING_PLAN.md for the v1 "warn-only" policy. `); } interface ObservabilityBlock { enabled?: boolean; logs?: { enabled?: boolean; head_sampling_rate?: number; persist?: boolean; destinations?: unknown[]; invocation_logs?: boolean; }; traces?: { enabled?: boolean; head_sampling_rate?: number; persist?: boolean; destinations?: unknown[]; }; } /** * Pure audit function. Exported for unit-testing; the CLI wrapper is the * thin sliver below. */ export function auditObservabilityBlock( obs: ObservabilityBlock | undefined, ): Finding[] { const findings: Finding[] = []; if (!obs) { findings.push({ id: "observability_missing", severity: "error", message: "wrangler.jsonc has no `observability` block. Cloudflare won't capture logs or traces.", fix: "npx -p @decocms/start deco-cf-observability --write", }); return findings; } if (obs.enabled === false) { findings.push({ id: "observability_disabled", severity: "error", message: "`observability.enabled: false` — the master switch is off, sub-block flags do nothing.", fix: "npx -p @decocms/start deco-cf-observability --write", }); } // ---- traces ---- if (obs.traces?.enabled === false) { findings.push({ id: "traces_disabled", severity: "warn", message: "`observability.traces.enabled: false` — traces won't reach the CF dashboard or any destination.", fix: "npx -p @decocms/start deco-cf-observability --write", }); } const tracesRate = obs.traces?.head_sampling_rate; if (typeof tracesRate === "number" && tracesRate > 0.01) { findings.push({ id: "head_sampling_rate_elevated", severity: "error", message: `traces.head_sampling_rate is ${tracesRate} (> 0.01). At fleet scale this is a cost trap; ` + `see docs/observability.md → Sampling. If this is intentional and time-bounded (incident, ` + `release window), leave a comment in wrangler.jsonc explaining why, then revert.`, fix: "npx -p @decocms/start deco-cf-observability --write --traces-rate 0.01", }); } // ---- logs ---- if (obs.logs?.enabled === false) { findings.push({ id: "logs_disabled", severity: "warn", message: "`observability.logs.enabled: false` — logs won't reach the CF dashboard or any destination.", fix: "npx -p @decocms/start deco-cf-observability --write", }); } const logsRate = obs.logs?.head_sampling_rate; if (typeof logsRate === "number" && logsRate < 1) { findings.push({ id: "logs_head_sampling_rate_low", severity: "warn", message: `logs.head_sampling_rate is ${logsRate} (< 1). Info/warn logs are cheap and high-signal; ` + `error logs already bypass head sampling via the direct-POST channel, so there's little to ` + `gain by sampling logs.`, fix: "npx -p @decocms/start deco-cf-observability --write --logs-rate 1", }); } // ---- persist / destinations ---- const hasDestination = (block?: { destinations?: unknown[] }): boolean => Array.isArray(block?.destinations) && block!.destinations!.length > 0; const tracesPersist = obs.traces?.persist ?? true; if ( obs.traces?.enabled !== false && !tracesPersist && !hasDestination(obs.traces) ) { findings.push({ id: "persist_disabled_no_destination", severity: "error", message: "traces.persist:false with no destinations — traces are captured and discarded. " + "Either set persist:true (CF dashboard storage) or configure a destination.", fix: "npx -p @decocms/start deco-cf-observability --write --persist", }); } const logsPersist = obs.logs?.persist ?? true; if ( obs.logs?.enabled !== false && !logsPersist && !hasDestination(obs.logs) ) { findings.push({ id: "persist_disabled_no_destination", severity: "error", message: "logs.persist:false with no destinations — logs are captured and discarded. " + "Either set persist:true (CF dashboard storage) or configure a destination.", fix: "npx -p @decocms/start deco-cf-observability --write --persist", }); } return findings; } /** * Fleet-config drift rules — owned by the same audit because the * cumulative effect of "observability block correct, bindings missing" * is identical to "observability block missing" (no data lands in * ClickHouse). * * The CLI composes `auditObservabilityBlock` + `auditFleetBindings`. * Both return Finding[]; callers concatenate. */ export interface WranglerLike { observability?: ObservabilityBlock; version_metadata?: { binding?: string } | unknown; analytics_engine_datasets?: Array<{ binding?: string; dataset?: string }> | unknown; tail_consumers?: Array<{ service?: string; environment?: string }> | unknown; vars?: Record | unknown; } export function auditFleetBindings(wrangler: WranglerLike): Finding[] { const findings: Finding[] = []; // version_metadata — required so `service.version` is stamped on every // span and log line. Without it, regressions can't be tied to a // specific deployment. const vmBinding = typeof wrangler.version_metadata === "object" && wrangler.version_metadata !== null && "binding" in wrangler.version_metadata ? (wrangler.version_metadata as { binding?: string }).binding : undefined; if (!vmBinding) { findings.push({ id: "version_metadata_binding_missing", severity: "error", message: "wrangler.jsonc is missing a `version_metadata.binding` entry. " + "`service.version` won't appear on spans/logs and the deploy-correlation " + "panel will be empty. Recommended value: `CF_VERSION_METADATA`.", fix: "npx -p @decocms/start deco-cf-observability --write", }); } // DECO_METRICS — Analytics Engine binding. The AE meter is the hot- // path CF dashboard view; OTLP works without it but we lose the // operator-grade short-window panels. const aeDatasets = Array.isArray(wrangler.analytics_engine_datasets) ? (wrangler.analytics_engine_datasets as Array<{ binding?: string }>) : []; const hasMetricsBinding = aeDatasets.some((d) => d?.binding === "DECO_METRICS"); if (!hasMetricsBinding) { findings.push({ id: "analytics_engine_binding_missing", severity: "warn", message: "wrangler.jsonc has no `analytics_engine_datasets[].binding == 'DECO_METRICS'`. " + "The AE meter is off; the hot-path operator dashboards in CF will be empty. " + "OTLP metrics keep flowing if `DECO_OTEL_METRICS_ENDPOINT` is set.", fix: "npx -p @decocms/start deco-cf-observability --write", }); } // tail_consumers — must list deco-otel-tail. Phase 1 enrichment is // useless without the tail consumer firing. const tail = Array.isArray(wrangler.tail_consumers) ? (wrangler.tail_consumers as Array<{ service?: string }>) : []; const hasTailConsumer = tail.some((t) => t?.service === "deco-otel-tail"); if (!hasTailConsumer) { findings.push({ id: "tail_consumer_missing", severity: "error", message: "wrangler.jsonc has no `tail_consumers[].service == 'deco-otel-tail'` entry. " + "100% error-capture is broken — only the head-sampled CF Destinations path " + "will report errors, and isolate crashes will be invisible.", fix: "npx -p @decocms/start deco-cf-observability --write", }); } // DECO_OTEL_*_ENDPOINT env vars. Audit each separately so the message // explains which channel is silently no-op. const vars = typeof wrangler.vars === "object" && wrangler.vars !== null ? (wrangler.vars as Record) : {}; const checkVar = (id: string, name: string, severity: Severity, channel: string) => { const v = vars[name]; if (typeof v !== "string" || v.length === 0) { findings.push({ id, severity, message: `wrangler.jsonc \`vars.${name}\` is not set. ${channel} is a no-op; ` + `data captured in this channel never lands in ClickHouse. ` + `See docs/observability.md for the canonical endpoints.`, fix: "npx -p @decocms/start deco-cf-observability --write", }); } }; checkVar( "otel_metrics_endpoint_missing", "DECO_OTEL_METRICS_ENDPOINT", "warn", "OTLP metrics direct-POST", ); checkVar( "otel_traces_endpoint_missing", "DECO_OTEL_TRACES_ENDPOINT", "warn", "OTLP traces direct-POST", ); checkVar( "otel_logs_endpoint_missing", "DECO_OTEL_LOGS_ENDPOINT", "warn", "OTLP error-log direct-POST", ); return findings; } /** * One-stop call for the full wrangler audit — composes the * observability-block rules with the fleet-binding rules. Both keep * working standalone for fine-grained tests. */ export function auditWranglerConfig(wrangler: WranglerLike): Finding[] { return [ ...auditObservabilityBlock(wrangler.observability), ...auditFleetBindings(wrangler), ]; } function findingsToText(file: string, findings: Finding[]): string { if (findings.length === 0) { return `OK ${file} — observability config looks canonical`; } const lines = [`Findings in ${file}:`]; for (const f of findings) { lines.push(` [${f.severity.toUpperCase()}] ${f.id}`); lines.push(` ${f.message}`); if (f.fix) lines.push(` fix: ${f.fix}`); } lines.push(""); return lines.join("\n"); } function main(): void { const opts = parseArgs(process.argv.slice(2)); if (opts.help) { showHelp(); process.exit(0); } const file = path.resolve(opts.source, "wrangler.jsonc"); if (!fs.existsSync(file)) { console.error(`audit: ${file} not found`); process.exit(2); } let parsed: WranglerLike; try { parsed = parseJsonc(fs.readFileSync(file, "utf8")) as WranglerLike; } catch (err) { console.error(`audit: ${file} could not be parsed: ${(err as Error).message}`); process.exit(2); } const findings = auditWranglerConfig(parsed); if (opts.json) { process.stdout.write( JSON.stringify({ file, mode: opts.mode, findings }, null, 2) + "\n", ); } else { process.stdout.write(findingsToText(file, findings) + "\n"); } if (opts.github) { for (const f of findings) { // GitHub Actions workflow command. `error` and `warning` annotate the // diff; `notice` is informational. We never emit `error` in warn mode // even for error-severity rules — the v1 policy is annotate-don't-fail. const level = opts.mode === "block" && f.severity === "error" ? "error" : f.severity === "info" ? "notice" : "warning"; const msg = `${f.message}${f.fix ? ` (fix: ${f.fix})` : ""}`; const escaped = msg.replace(/%/g, "%25").replace(/\r/g, "%0D").replace( /\n/g, "%0A", ); process.stdout.write(`::${level} title=${f.id}::${escaped}\n`); } } // Exit policy: D-16 / Phase 6 decision. // warn — annotate only; always exit 0 (CI sees the findings but ships) // block — exit 1 on any `error`-severity finding // The default is `warn` because storefronts are upgraded over weeks; a // day-one block would fail PRs that have nothing to do with observability. const shouldFail = opts.mode === "block" && findings.some((f) => f.severity === "error"); process.exit(shouldFail ? 1 : 0); } // Only run when invoked directly, not when imported by tests. // Works under both CJS (require.main === module) and ESM (import.meta.url // matches argv[1]) because the package is `"type": "module"` but the // codemod siblings ship .cjs bundles via tsup. const isCjsEntry = typeof require !== "undefined" && typeof module !== "undefined" && // biome-ignore lint/correctness/noNodejsModules: entry-point check require.main === module; let isEsmEntry = false; try { // import.meta is a syntax error in CJS, but we're in an ESM source file. isEsmEntry = typeof process !== "undefined" && Array.isArray(process.argv) && process.argv[1] !== undefined && import.meta.url === `file://${process.argv[1]}`; } catch { // ignore in CJS } if (isCjsEntry || isEsmEntry) { main(); }