#!/usr/bin/env tsx /** * Cloudflare-native observability codemod * * Rewrites a migrated site's `wrangler.jsonc` so the Cloudflare runtime * captures `console.*` logs and auto-instrumented traces directly into * the per-Worker observability dashboard. No in-Worker exporter, no * external destination — the CF dashboard is the destination. * * The canonical block this script writes: * * "observability": { * "enabled": true, * "logs": { "enabled": true, "invocation_logs": true, * "head_sampling_rate": 1, "persist": true }, * "traces": { "enabled": true, * "head_sampling_rate": 0.1, "persist": true } * } * * `enabled: true` at the top level is the master switch — without it * Cloudflare captures nothing, regardless of the sub-block flags. * `persist: true` keeps the data queryable in the CF dashboard * (Workers Logs view + Traces view). Discovered the hard way during the * production canary cutover. * * Behavior: * - dry-run by default — prints a unified diff against the existing * observability block. Safe in CI. * - `--write` performs the in-place edit. The script: * 1. locates the existing `"observability": { ... }` block * (matching balanced braces, JSONC-comment-aware), * 2. replaces it with the canonical CF-native block, OR * 3. appends a new block before the trailing `}` if no * observability key exists yet, * 4. validates the result parses as JSON (after stripping * comments) before writing. * - Idempotent: a wrangler.jsonc already on the canonical block is a * no-op. A wrangler.jsonc with stale HyperDX-style destinations is * rewritten to drop them. * - Forwarding to an external destination (an OTel collector for * ClickHouse, a third-party SaaS, etc.) is opt-in via * `--destination-logs` / `--destination-traces`. The destination * itself must be provisioned out-of-band in the CF dashboard. * * Usage (from a migrated site directory): * npx -p @decocms/start deco-cf-observability # dry-run * npx -p @decocms/start deco-cf-observability --write # apply * * # Opt-in: also forward to an account-level destination: * npx -p @decocms/start deco-cf-observability --write \ * --destination-logs my-logs-dest --destination-traces my-traces-dest * * Options: * --source Site directory containing wrangler.jsonc (default: cwd) * --write Apply the change. Otherwise prints diff and exits 1. * --destination-logs Optional CF destination name to forward logs to. * --destination-traces Optional CF destination name to forward traces to. * --traces-rate head_sampling_rate for traces (default: 0.1) * --logs-rate head_sampling_rate for logs (default: 1.0) * --no-persist Set persist:false (do not keep data in CF dashboard) * --persist Set persist:true (default — required if no destination) * --help, -h Show this help * * Exit codes: * 0 — no change needed (already canonical), or dry-run completed cleanly * 1 — change required and `--write` not passed (CI signal) * 2 — file invalid / can't parse / can't safely edit */ import * as fs from "node:fs"; import * as path from "node:path"; interface CliOpts { source: string; write: boolean; /** Optional CF destination slug for logs. Empty = no forwarding. */ logsDest: string; /** Optional CF destination slug for traces. Empty = no forwarding. */ tracesDest: string; tracesRate: number; logsRate: number; persist: boolean; help: boolean; } function parseArgs(argv: string[]): CliOpts { const opts: CliOpts = { source: ".", write: false, logsDest: "", tracesDest: "", tracesRate: 0.1, logsRate: 1.0, // CF dashboard persistence on by default — without either persist:true // OR a destination, observability data is captured and discarded. persist: true, help: false, }; for (let i = 0; i < argv.length; i++) { const flag = argv[i]; switch (flag) { case "--source": opts.source = argv[++i] ?? "."; break; case "--write": opts.write = true; break; case "--destination-logs": opts.logsDest = argv[++i] ?? ""; break; case "--destination-traces": opts.tracesDest = argv[++i] ?? ""; break; case "--traces-rate": opts.tracesRate = Number(argv[++i] ?? opts.tracesRate); break; case "--logs-rate": opts.logsRate = Number(argv[++i] ?? opts.logsRate); break; case "--persist": opts.persist = true; break; case "--no-persist": opts.persist = false; break; case "--help": case "-h": opts.help = true; break; } } return opts; } function showHelp(): void { console.log(` @decocms/start — Cloudflare-native observability codemod Rewrites wrangler.jsonc so Cloudflare captures \`console.*\` logs and auto-instrumented traces directly into the per-Worker dashboard. No in-Worker exporter SDK, no external destination required. Usage: npx -p @decocms/start deco-cf-observability [options] Options: --source Site directory (default: .) --write Apply the edit. Without it, prints diff and exits 1. --destination-logs Optional CF destination slug to also forward logs to. --destination-traces Optional CF destination slug to also forward traces to. --traces-rate head_sampling_rate for traces (default: 0.1) --logs-rate head_sampling_rate for logs (default: 1.0) --persist Keep the dashboard storage tier (default) --no-persist Drop the dashboard tier (only sane when forwarding) --help, -h This message After running with --write you must: 1. Deploy the Worker (\`wrangler deploy\`). 2. Verify the CF dashboard shows logs + traces within ~5 min: Workers & Pages → → Observability 3. If migrating from an older app-side OTLP setup, delete the now-orphaned secrets: wrangler secret delete OTEL_EXPORTER_OTLP_ENDPOINT \\ OTEL_EXPORTER_OTLP_HEADERS \\ OTEL_SAMPLING_CONFIG \\ OTEL_LOG_MIN_SEVERITY `); } // --------------------------------------------------------------------------- // JSONC handling (no external deps — vendored mini-stripper) // --------------------------------------------------------------------------- /** * Strip line and block comments from a JSONC string so the result parses * with vanilla `JSON.parse`. Preserves quoted strings (handles escaped * quotes), preserves whitespace/newlines so line numbers in error * messages stay stable. */ function stripJsoncComments(src: string): string { let out = ""; let i = 0; let inString = false; let stringQuote = ""; while (i < src.length) { const ch = src[i]; const next = src[i + 1]; if (inString) { out += ch; if (ch === "\\" && i + 1 < src.length) { out += next; i += 2; continue; } if (ch === stringQuote) { inString = false; } i++; continue; } if (ch === '"' || ch === "'") { inString = true; stringQuote = ch; out += ch; i++; continue; } if (ch === "/" && next === "/") { // Line comment — skip to newline (preserve newline for line counts). while (i < src.length && src[i] !== "\n") i++; continue; } if (ch === "/" && next === "*") { // Block comment — skip to */, preserving newlines for line counts. i += 2; while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) { if (src[i] === "\n") out += "\n"; i++; } i += 2; continue; } out += ch; i++; } return out; } /** * Locate the value range of a top-level JSON object key inside JSONC text. * Returns the absolute character span of the value (the contents between * the opening `{` and matching closing `}`) and the span of the entire * `"key": value` pair, including the key and surrounding whitespace * adequate for clean removal. * * Returns `null` when the key isn't found at the top level. * * Brace-counting is JSONC-aware (skips comments and strings). */ function findTopLevelKeyRange( src: string, key: string, ): { keyStart: number; valueEnd: number } | null { // Walk to find the opening `{` of the top-level object first. let i = 0; let inString = false; let stringQuote = ""; // Find first `{` while (i < src.length) { const ch = src[i]; const next = src[i + 1]; if (ch === "/" && next === "/") { while (i < src.length && src[i] !== "\n") i++; continue; } if (ch === "/" && next === "*") { i += 2; while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++; i += 2; continue; } if (ch === "{") break; i++; } if (i >= src.length) return null; // Now scan inside the top-level object, depth-tracked, looking for our key. // Top-level keys appear at depth 1. let depth = 1; i++; const needle = `"${key}"`; while (i < src.length) { const ch = src[i]; const next = src[i + 1]; if (inString) { if (ch === "\\" && i + 1 < src.length) { i += 2; continue; } if (ch === stringQuote) inString = false; i++; continue; } if (ch === '"') { // Possible key match. Check. if (depth === 1 && src.startsWith(needle, i)) { const keyStart = i; // Advance past the matched key string. i += needle.length; // Skip whitespace + `:` while (i < src.length && /\s/.test(src[i])) i++; if (src[i] !== ":") return null; i++; while (i < src.length && /\s/.test(src[i])) i++; // Now we're at the value. Find its end (handle objects, arrays, strings, primitives). if (src[i] === "{" || src[i] === "[") { const open = src[i]; const close = open === "{" ? "}" : "]"; let d = 1; i++; while (i < src.length && d > 0) { const c2 = src[i]; const n2 = src[i + 1]; if (c2 === '"' || c2 === "'") { const q = c2; i++; while (i < src.length) { if (src[i] === "\\") { i += 2; continue; } if (src[i] === q) { i++; break; } i++; } continue; } if (c2 === "/" && n2 === "/") { while (i < src.length && src[i] !== "\n") i++; continue; } if (c2 === "/" && n2 === "*") { i += 2; while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++; i += 2; continue; } if (c2 === open) d++; else if (c2 === close) d--; i++; } return { keyStart, valueEnd: i }; } // Primitive / string value — read until comma or closing brace at depth 1. while (i < src.length && src[i] !== "," && src[i] !== "}" && src[i] !== "\n") i++; return { keyStart, valueEnd: i }; } inString = true; stringQuote = '"'; i++; continue; } if (ch === "/" && next === "/") { while (i < src.length && src[i] !== "\n") i++; continue; } if (ch === "/" && next === "*") { i += 2; while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++; i += 2; continue; } if (ch === "{") depth++; else if (ch === "}") { depth--; if (depth === 0) return null; } i++; } return null; } /** * Locate the end of the top-level object (position of the closing `}`). * Used when appending a new key. JSONC-aware. */ function findTopLevelObjectEnd(src: string): number | null { let i = 0; let inString = false; let stringQuote = ""; // Find first `{` while (i < src.length) { const ch = src[i]; const next = src[i + 1]; if (ch === "/" && next === "/") { while (i < src.length && src[i] !== "\n") i++; continue; } if (ch === "/" && next === "*") { i += 2; while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++; i += 2; continue; } if (ch === "{") break; i++; } if (i >= src.length) return null; let depth = 1; i++; while (i < src.length) { const ch = src[i]; const next = src[i + 1]; if (inString) { if (ch === "\\" && i + 1 < src.length) { i += 2; continue; } if (ch === stringQuote) inString = false; i++; continue; } if (ch === '"' || ch === "'") { inString = true; stringQuote = ch; i++; continue; } if (ch === "/" && next === "/") { while (i < src.length && src[i] !== "\n") i++; continue; } if (ch === "/" && next === "*") { i += 2; while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++; i += 2; continue; } if (ch === "{") depth++; else if (ch === "}") { depth--; if (depth === 0) return i; } i++; } return null; } // --------------------------------------------------------------------------- // Block rendering // --------------------------------------------------------------------------- function renderObservabilityBlock(opts: CliOpts, indent = " "): string { const persist = opts.persist; const lines: string[] = [ `"observability": {`, `${indent}// Master switch — without enabled:true at the top level CF`, `${indent}// captures nothing, regardless of the sub-block flags.`, `${indent}"enabled": true,`, `${indent}// Cloudflare captures every console.* call from the Worker`, `${indent}// (structured JSON via @decocms/start's logger lands here too).`, `${indent}// persist:true keeps them queryable in the CF dashboard.`, `${indent}"logs": {`, `${indent}${indent}"enabled": true,`, `${indent}${indent}"invocation_logs": true,`, `${indent}${indent}"head_sampling_rate": ${opts.logsRate},`, `${indent}${indent}"persist": ${persist}`, ]; if (opts.logsDest) { // Replace the trailing line with a comma'd version, then append the // destinations array. Keeps the block valid JSON either way. lines[lines.length - 1] = `${indent}${indent}"persist": ${persist},`; lines.push(`${indent}${indent}"destinations": ["${opts.logsDest}"]`); } lines.push( `${indent}},`, `${indent}// Cloudflare auto-instruments fetch/KV/R2/DO subrequests and`, `${indent}// also picks up @opentelemetry/api global-tracer spans the`, `${indent}// framework's withTracing() helper emits.`, `${indent}"traces": {`, `${indent}${indent}"enabled": true,`, `${indent}${indent}"head_sampling_rate": ${opts.tracesRate},`, `${indent}${indent}"persist": ${persist}`, ); if (opts.tracesDest) { lines[lines.length - 1] = `${indent}${indent}"persist": ${persist},`; lines.push(`${indent}${indent}"destinations": ["${opts.tracesDest}"]`); } lines.push(`${indent}}`, `}`); return lines.join("\n"); } // --------------------------------------------------------------------------- // Detect "already canonical" // --------------------------------------------------------------------------- /** * A wrangler.jsonc is considered canonical when it has the master * `enabled: true` switch under `observability`, both `logs` and * `traces` sub-blocks present, AND the `destinations` arrays match * what `--destination-logs` / `--destination-traces` requested (which * is "absent" by default). This means a stale HyperDX-style * destination array always triggers a rewrite even if the rest of the * shape happens to match. */ function isAlreadyCanonical(src: string, opts: CliOpts): boolean { // Fast structural checks — full JSONC parse only if they pass. if (!src.includes(`"observability"`)) return false; if (!src.includes(`"enabled": true`) && !src.includes(`"enabled":true`)) { return false; } let parsed: unknown; try { parsed = JSON.parse(stripJsoncComments(src)); } catch { return false; } if (!parsed || typeof parsed !== "object") return false; const obs = (parsed as Record).observability; if (!obs || typeof obs !== "object") return false; const obsObj = obs as Record; if (obsObj.enabled !== true) return false; const checkLeaf = (leaf: unknown, expectedDest: string): boolean => { if (!leaf || typeof leaf !== "object") return false; const l = leaf as Record; if (l.enabled !== true) return false; const dests = l.destinations; if (expectedDest === "") { // Caller wants no destinations. Tolerate either absent or an // empty array; reject any non-empty array. if (dests === undefined) return true; return Array.isArray(dests) && dests.length === 0; } if (!Array.isArray(dests) || dests.length !== 1) return false; return dests[0] === expectedDest; }; if (!checkLeaf(obsObj.logs, opts.logsDest)) return false; if (!checkLeaf(obsObj.traces, opts.tracesDest)) return false; return true; } // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- function validateJson(src: string): { ok: true } | { ok: false; error: string } { try { JSON.parse(stripJsoncComments(src)); return { ok: true }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; } } // --------------------------------------------------------------------------- // Diff (tiny line-level) // --------------------------------------------------------------------------- function unifiedDiff(before: string, after: string, file: string): string { const a = before.split("\n"); const b = after.split("\n"); // Find the changed window — it's always contiguous because we only edit // one block. Keep it brain-simple: shrink both ends, print the rest with // -/+ markers. let i = 0; while (i < a.length && i < b.length && a[i] === b[i]) i++; let endA = a.length; let endB = b.length; while (endA > i && endB > i && a[endA - 1] === b[endB - 1]) { endA--; endB--; } const ctxStart = Math.max(0, i - 3); const ctxAEnd = Math.min(a.length, endA + 3); const ctxBEnd = Math.min(b.length, endB + 3); const lines: string[] = []; lines.push(`--- ${file} (before)`); lines.push(`+++ ${file} (after)`); for (let k = ctxStart; k < i; k++) lines.push(` ${a[k]}`); for (let k = i; k < endA; k++) lines.push(`- ${a[k]}`); for (let k = i; k < endB; k++) lines.push(`+ ${b[k]}`); for (let k = endA; k < ctxAEnd; k++) lines.push(` ${a[k]}`); // ctxBEnd guards equality at the tail; printing either tail context is fine. void ctxBEnd; return lines.join("\n"); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- /** * Detect the column-0..keyStart whitespace of the line containing * `keyStart`, so we can re-indent every line of the rendered block to * match the file's existing nesting depth. */ function detectLineIndent(src: string, position: number): string { let lineStart = position; while (lineStart > 0 && src[lineStart - 1] !== "\n") lineStart--; let i = lineStart; while (i < src.length && (src[i] === " " || src[i] === "\t")) i++; return src.slice(lineStart, i); } function reindentBlockBody(block: string, indent: string): string { const lines = block.split("\n"); // Leave line 0 alone — it's spliced in at the existing key position // which is already indented by surrounding text. Re-indent the rest. return [lines[0], ...lines.slice(1).map((l) => indent + l)].join("\n"); } function applyEdit(src: string, opts: CliOpts): string { const block = renderObservabilityBlock(opts); const range = findTopLevelKeyRange(src, "observability"); if (range) { // Replace the existing `"observability": {...}` (key + value) with the // new block. Re-indent body lines to match the existing key's column // so the final wrangler.jsonc stays cleanly formatted. const indent = detectLineIndent(src, range.keyStart); const indentedBlock = reindentBlockBody(block, indent); return src.slice(0, range.keyStart) + indentedBlock + src.slice(range.valueEnd); } // No observability key — append before the closing `}` of the top-level object. const end = findTopLevelObjectEnd(src); if (end == null) { throw new Error("wrangler.jsonc: could not locate top-level closing `}`"); } // Walk back from the closing `}` over whitespace to find the last // non-whitespace character. We splice in two pieces: // - the comma (if needed) goes immediately AFTER that char so it // sits on the same line as the prior key, not on a line of its own // - the new key + value goes right before the closing `}` let scan = end - 1; while (scan >= 0 && /\s/.test(src[scan])) scan--; const prevChar = scan >= 0 ? src[scan] : ""; const needsComma = prevChar !== "{" && prevChar !== ","; const baseIndent = " "; const indented = block .split("\n") .map((l) => baseIndent + l) .join("\n"); if (needsComma) { const commaInsertAt = scan + 1; const before = `${src.slice(0, commaInsertAt)},`; // Preserve any whitespace/newlines that were between the prior key // and the closing `}` so the new block lines up under existing // indentation conventions. const between = src.slice(commaInsertAt, end); return `${before}${between}${indented}\n${src.slice(end)}`; } return `${src.slice(0, end)}${indented}\n${src.slice(end)}`; } function main(): void { const opts = parseArgs(process.argv.slice(2)); if (opts.help) { showHelp(); return; } const wranglerPath = path.join(path.resolve(opts.source), "wrangler.jsonc"); if (!fs.existsSync(wranglerPath)) { console.error(`error: ${wranglerPath} does not exist`); process.exit(2); } const before = fs.readFileSync(wranglerPath, "utf8"); if (isAlreadyCanonical(before, opts)) { console.log(`${wranglerPath} already on the canonical CF observability block — no change.`); process.exit(0); } let after: string; try { after = applyEdit(before, opts); } catch (e) { console.error(`error: ${e instanceof Error ? e.message : String(e)}`); process.exit(2); } const validation = validateJson(after); if (!validation.ok) { console.error(`error: result wouldn't parse as JSONC: ${validation.error}`); console.error("aborting; no changes written."); process.exit(2); } if (!opts.write) { console.log(unifiedDiff(before, after, wranglerPath)); console.log("\nDry-run only. Re-run with --write to apply."); process.exit(1); } fs.writeFileSync(wranglerPath, after, "utf8"); console.log(`wrote ${wranglerPath}`); console.log(` Next steps: 1. wrangler deploy 2. Verify CF dashboard captures logs + traces (~5 min): Workers & Pages → → Observability 3. If migrating from an older app-side OTLP setup, delete the now-orphaned secrets: wrangler secret delete OTEL_EXPORTER_OTLP_ENDPOINT \\ OTEL_EXPORTER_OTLP_HEADERS \\ OTEL_SAMPLING_CONFIG \\ OTEL_LOG_MIN_SEVERITY `); } main();