@decocms/start 4.3.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bun.lock +36 -110
- package/package.json +4 -3
- package/scripts/migrate/phase-report.ts +8 -0
- package/scripts/migrate/templates/server-entry.ts +39 -3
- package/scripts/migrate-to-cf-observability.test.ts +169 -0
- package/scripts/migrate-to-cf-observability.ts +611 -0
- package/src/sdk/logger.test.ts +79 -0
- package/src/sdk/logger.ts +40 -2
- package/src/sdk/otel.test.ts +128 -15
- package/src/sdk/otel.ts +179 -98
- package/src/sdk/sampler.ts +17 -5
- package/src/sdk/workerEntry.ts +7 -4
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Cloudflare-native observability codemod
|
|
4
|
+
*
|
|
5
|
+
* Rewrites a migrated site's `wrangler.jsonc` so Cloudflare ships
|
|
6
|
+
* `console.*` logs and OTel traces directly to HyperDX (or any other
|
|
7
|
+
* OTLP destination provisioned in the CF dashboard) — replacing the
|
|
8
|
+
* in-Worker OTLP exporter that `@decocms/start` ≤ 4.3.x bundled.
|
|
9
|
+
*
|
|
10
|
+
* Behavior:
|
|
11
|
+
* - dry-run by default — prints the proposed `observability` block
|
|
12
|
+
* plus a unified diff against the existing one. Safe to run
|
|
13
|
+
* unattended in CI.
|
|
14
|
+
* - `--write` performs the in-place edit. The script:
|
|
15
|
+
* 1. locates the existing `"observability": { ... }` block
|
|
16
|
+
* (matching balanced braces, JSONC-comment-aware),
|
|
17
|
+
* 2. replaces it with the canonical CF-native block, OR
|
|
18
|
+
* 3. appends a new block before the trailing `}` if no
|
|
19
|
+
* observability key exists yet,
|
|
20
|
+
* 4. validates the result parses as JSON (after stripping
|
|
21
|
+
* comments) before writing.
|
|
22
|
+
* - Idempotent: running twice produces the same file.
|
|
23
|
+
*
|
|
24
|
+
* Usage (from a migrated site directory):
|
|
25
|
+
* npx -p @decocms/start deco-cf-observability # dry-run
|
|
26
|
+
* npx -p @decocms/start deco-cf-observability --write # apply
|
|
27
|
+
* npx -p @decocms/start deco-cf-observability --logs hyperdx-logs --traces hyperdx-traces --write
|
|
28
|
+
*
|
|
29
|
+
* Options:
|
|
30
|
+
* --source <dir> Site directory containing wrangler.jsonc (default: cwd)
|
|
31
|
+
* --write Apply the change. Otherwise prints diff and exits.
|
|
32
|
+
* --logs <name> Logs destination name (default: "hyperdx-logs")
|
|
33
|
+
* --traces <name> Traces destination name (default: "hyperdx-traces")
|
|
34
|
+
* --traces-rate <r> head_sampling_rate for traces (default: 0.1)
|
|
35
|
+
* --logs-rate <r> head_sampling_rate for logs (default: 1.0)
|
|
36
|
+
* --no-persist Set persist:false (default — saves CF dashboard storage cost)
|
|
37
|
+
* --persist Set persist:true (keep traces/logs in the CF dashboard)
|
|
38
|
+
* --help, -h Show this help
|
|
39
|
+
*
|
|
40
|
+
* Exit codes:
|
|
41
|
+
* 0 — no change needed (already CF-native), or dry-run completed
|
|
42
|
+
* 1 — change required and `--write` not passed (CI signal)
|
|
43
|
+
* 2 — file invalid / can't parse / can't safely edit
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import * as fs from "node:fs";
|
|
47
|
+
import * as path from "node:path";
|
|
48
|
+
|
|
49
|
+
interface CliOpts {
|
|
50
|
+
source: string;
|
|
51
|
+
write: boolean;
|
|
52
|
+
logsDest: string;
|
|
53
|
+
tracesDest: string;
|
|
54
|
+
tracesRate: number;
|
|
55
|
+
logsRate: number;
|
|
56
|
+
persist: boolean;
|
|
57
|
+
help: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseArgs(argv: string[]): CliOpts {
|
|
61
|
+
const opts: CliOpts = {
|
|
62
|
+
source: ".",
|
|
63
|
+
write: false,
|
|
64
|
+
logsDest: "hyperdx-logs",
|
|
65
|
+
tracesDest: "hyperdx-traces",
|
|
66
|
+
tracesRate: 0.1,
|
|
67
|
+
logsRate: 1.0,
|
|
68
|
+
persist: false,
|
|
69
|
+
help: false,
|
|
70
|
+
};
|
|
71
|
+
for (let i = 0; i < argv.length; i++) {
|
|
72
|
+
const flag = argv[i];
|
|
73
|
+
switch (flag) {
|
|
74
|
+
case "--source":
|
|
75
|
+
opts.source = argv[++i] ?? ".";
|
|
76
|
+
break;
|
|
77
|
+
case "--write":
|
|
78
|
+
opts.write = true;
|
|
79
|
+
break;
|
|
80
|
+
case "--logs":
|
|
81
|
+
opts.logsDest = argv[++i] ?? opts.logsDest;
|
|
82
|
+
break;
|
|
83
|
+
case "--traces":
|
|
84
|
+
opts.tracesDest = argv[++i] ?? opts.tracesDest;
|
|
85
|
+
break;
|
|
86
|
+
case "--traces-rate":
|
|
87
|
+
opts.tracesRate = Number(argv[++i] ?? opts.tracesRate);
|
|
88
|
+
break;
|
|
89
|
+
case "--logs-rate":
|
|
90
|
+
opts.logsRate = Number(argv[++i] ?? opts.logsRate);
|
|
91
|
+
break;
|
|
92
|
+
case "--persist":
|
|
93
|
+
opts.persist = true;
|
|
94
|
+
break;
|
|
95
|
+
case "--no-persist":
|
|
96
|
+
opts.persist = false;
|
|
97
|
+
break;
|
|
98
|
+
case "--help":
|
|
99
|
+
case "-h":
|
|
100
|
+
opts.help = true;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return opts;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function showHelp(): void {
|
|
108
|
+
console.log(`
|
|
109
|
+
@decocms/start — Cloudflare-native observability codemod
|
|
110
|
+
|
|
111
|
+
Rewrites wrangler.jsonc to ship logs and traces via Cloudflare's
|
|
112
|
+
platform-managed OTLP export (observability.{logs,traces}.destinations)
|
|
113
|
+
instead of the in-Worker exporter SDK.
|
|
114
|
+
|
|
115
|
+
Usage:
|
|
116
|
+
npx -p @decocms/start deco-cf-observability [options]
|
|
117
|
+
|
|
118
|
+
Options:
|
|
119
|
+
--source <dir> Site directory (default: .)
|
|
120
|
+
--write Apply the edit. Without it, prints diff and exits 1.
|
|
121
|
+
--logs <name> Logs destination (default: hyperdx-logs)
|
|
122
|
+
--traces <name> Traces destination (default: hyperdx-traces)
|
|
123
|
+
--traces-rate <r> head_sampling_rate for traces (default: 0.1)
|
|
124
|
+
--logs-rate <r> head_sampling_rate for logs (default: 1.0)
|
|
125
|
+
--persist Keep the dashboard storage tier (default: --no-persist)
|
|
126
|
+
--help, -h This message
|
|
127
|
+
|
|
128
|
+
After running with --write you must:
|
|
129
|
+
1. Provision the destinations in the CF dashboard (one-time per account)
|
|
130
|
+
2. Deploy the Worker
|
|
131
|
+
3. Validate signals are landing in HyperDX
|
|
132
|
+
4. Delete the now-orphaned secrets:
|
|
133
|
+
wrangler secret delete OTEL_EXPORTER_OTLP_ENDPOINT \\
|
|
134
|
+
OTEL_EXPORTER_OTLP_HEADERS \\
|
|
135
|
+
OTEL_SAMPLING_CONFIG \\
|
|
136
|
+
OTEL_LOG_MIN_SEVERITY
|
|
137
|
+
`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// JSONC handling (no external deps — vendored mini-stripper)
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Strip line and block comments from a JSONC string so the result parses
|
|
146
|
+
* with vanilla `JSON.parse`. Preserves quoted strings (handles escaped
|
|
147
|
+
* quotes), preserves whitespace/newlines so line numbers in error
|
|
148
|
+
* messages stay stable.
|
|
149
|
+
*/
|
|
150
|
+
function stripJsoncComments(src: string): string {
|
|
151
|
+
let out = "";
|
|
152
|
+
let i = 0;
|
|
153
|
+
let inString = false;
|
|
154
|
+
let stringQuote = "";
|
|
155
|
+
while (i < src.length) {
|
|
156
|
+
const ch = src[i];
|
|
157
|
+
const next = src[i + 1];
|
|
158
|
+
if (inString) {
|
|
159
|
+
out += ch;
|
|
160
|
+
if (ch === "\\" && i + 1 < src.length) {
|
|
161
|
+
out += next;
|
|
162
|
+
i += 2;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (ch === stringQuote) {
|
|
166
|
+
inString = false;
|
|
167
|
+
}
|
|
168
|
+
i++;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (ch === '"' || ch === "'") {
|
|
172
|
+
inString = true;
|
|
173
|
+
stringQuote = ch;
|
|
174
|
+
out += ch;
|
|
175
|
+
i++;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (ch === "/" && next === "/") {
|
|
179
|
+
// Line comment — skip to newline (preserve newline for line counts).
|
|
180
|
+
while (i < src.length && src[i] !== "\n") i++;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (ch === "/" && next === "*") {
|
|
184
|
+
// Block comment — skip to */, preserving newlines for line counts.
|
|
185
|
+
i += 2;
|
|
186
|
+
while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) {
|
|
187
|
+
if (src[i] === "\n") out += "\n";
|
|
188
|
+
i++;
|
|
189
|
+
}
|
|
190
|
+
i += 2;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
out += ch;
|
|
194
|
+
i++;
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Locate the value range of a top-level JSON object key inside JSONC text.
|
|
201
|
+
* Returns the absolute character span of the value (the contents between
|
|
202
|
+
* the opening `{` and matching closing `}`) and the span of the entire
|
|
203
|
+
* `"key": value` pair, including the key and surrounding whitespace
|
|
204
|
+
* adequate for clean removal.
|
|
205
|
+
*
|
|
206
|
+
* Returns `null` when the key isn't found at the top level.
|
|
207
|
+
*
|
|
208
|
+
* Brace-counting is JSONC-aware (skips comments and strings).
|
|
209
|
+
*/
|
|
210
|
+
function findTopLevelKeyRange(
|
|
211
|
+
src: string,
|
|
212
|
+
key: string,
|
|
213
|
+
): { keyStart: number; valueEnd: number } | null {
|
|
214
|
+
// Walk to find the opening `{` of the top-level object first.
|
|
215
|
+
let i = 0;
|
|
216
|
+
let inString = false;
|
|
217
|
+
let stringQuote = "";
|
|
218
|
+
|
|
219
|
+
// Find first `{`
|
|
220
|
+
while (i < src.length) {
|
|
221
|
+
const ch = src[i];
|
|
222
|
+
const next = src[i + 1];
|
|
223
|
+
if (ch === "/" && next === "/") {
|
|
224
|
+
while (i < src.length && src[i] !== "\n") i++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (ch === "/" && next === "*") {
|
|
228
|
+
i += 2;
|
|
229
|
+
while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++;
|
|
230
|
+
i += 2;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (ch === "{") break;
|
|
234
|
+
i++;
|
|
235
|
+
}
|
|
236
|
+
if (i >= src.length) return null;
|
|
237
|
+
|
|
238
|
+
// Now scan inside the top-level object, depth-tracked, looking for our key.
|
|
239
|
+
// Top-level keys appear at depth 1.
|
|
240
|
+
let depth = 1;
|
|
241
|
+
i++;
|
|
242
|
+
const needle = `"${key}"`;
|
|
243
|
+
while (i < src.length) {
|
|
244
|
+
const ch = src[i];
|
|
245
|
+
const next = src[i + 1];
|
|
246
|
+
if (inString) {
|
|
247
|
+
if (ch === "\\" && i + 1 < src.length) {
|
|
248
|
+
i += 2;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (ch === stringQuote) inString = false;
|
|
252
|
+
i++;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (ch === '"') {
|
|
256
|
+
// Possible key match. Check.
|
|
257
|
+
if (depth === 1 && src.startsWith(needle, i)) {
|
|
258
|
+
const keyStart = i;
|
|
259
|
+
// Advance past the matched key string.
|
|
260
|
+
i += needle.length;
|
|
261
|
+
// Skip whitespace + `:`
|
|
262
|
+
while (i < src.length && /\s/.test(src[i])) i++;
|
|
263
|
+
if (src[i] !== ":") return null;
|
|
264
|
+
i++;
|
|
265
|
+
while (i < src.length && /\s/.test(src[i])) i++;
|
|
266
|
+
// Now we're at the value. Find its end (handle objects, arrays, strings, primitives).
|
|
267
|
+
if (src[i] === "{" || src[i] === "[") {
|
|
268
|
+
const open = src[i];
|
|
269
|
+
const close = open === "{" ? "}" : "]";
|
|
270
|
+
let d = 1;
|
|
271
|
+
i++;
|
|
272
|
+
while (i < src.length && d > 0) {
|
|
273
|
+
const c2 = src[i];
|
|
274
|
+
const n2 = src[i + 1];
|
|
275
|
+
if (c2 === '"' || c2 === "'") {
|
|
276
|
+
const q = c2;
|
|
277
|
+
i++;
|
|
278
|
+
while (i < src.length) {
|
|
279
|
+
if (src[i] === "\\") {
|
|
280
|
+
i += 2;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (src[i] === q) {
|
|
284
|
+
i++;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
i++;
|
|
288
|
+
}
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (c2 === "/" && n2 === "/") {
|
|
292
|
+
while (i < src.length && src[i] !== "\n") i++;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (c2 === "/" && n2 === "*") {
|
|
296
|
+
i += 2;
|
|
297
|
+
while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++;
|
|
298
|
+
i += 2;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (c2 === open) d++;
|
|
302
|
+
else if (c2 === close) d--;
|
|
303
|
+
i++;
|
|
304
|
+
}
|
|
305
|
+
return { keyStart, valueEnd: i };
|
|
306
|
+
}
|
|
307
|
+
// Primitive / string value — read until comma or closing brace at depth 1.
|
|
308
|
+
while (i < src.length && src[i] !== "," && src[i] !== "}" && src[i] !== "\n") i++;
|
|
309
|
+
return { keyStart, valueEnd: i };
|
|
310
|
+
}
|
|
311
|
+
inString = true;
|
|
312
|
+
stringQuote = '"';
|
|
313
|
+
i++;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (ch === "/" && next === "/") {
|
|
317
|
+
while (i < src.length && src[i] !== "\n") i++;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (ch === "/" && next === "*") {
|
|
321
|
+
i += 2;
|
|
322
|
+
while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++;
|
|
323
|
+
i += 2;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (ch === "{") depth++;
|
|
327
|
+
else if (ch === "}") {
|
|
328
|
+
depth--;
|
|
329
|
+
if (depth === 0) return null;
|
|
330
|
+
}
|
|
331
|
+
i++;
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Locate the end of the top-level object (position of the closing `}`).
|
|
338
|
+
* Used when appending a new key. JSONC-aware.
|
|
339
|
+
*/
|
|
340
|
+
function findTopLevelObjectEnd(src: string): number | null {
|
|
341
|
+
let i = 0;
|
|
342
|
+
let inString = false;
|
|
343
|
+
let stringQuote = "";
|
|
344
|
+
|
|
345
|
+
// Find first `{`
|
|
346
|
+
while (i < src.length) {
|
|
347
|
+
const ch = src[i];
|
|
348
|
+
const next = src[i + 1];
|
|
349
|
+
if (ch === "/" && next === "/") {
|
|
350
|
+
while (i < src.length && src[i] !== "\n") i++;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (ch === "/" && next === "*") {
|
|
354
|
+
i += 2;
|
|
355
|
+
while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++;
|
|
356
|
+
i += 2;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (ch === "{") break;
|
|
360
|
+
i++;
|
|
361
|
+
}
|
|
362
|
+
if (i >= src.length) return null;
|
|
363
|
+
let depth = 1;
|
|
364
|
+
i++;
|
|
365
|
+
while (i < src.length) {
|
|
366
|
+
const ch = src[i];
|
|
367
|
+
const next = src[i + 1];
|
|
368
|
+
if (inString) {
|
|
369
|
+
if (ch === "\\" && i + 1 < src.length) {
|
|
370
|
+
i += 2;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (ch === stringQuote) inString = false;
|
|
374
|
+
i++;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (ch === '"' || ch === "'") {
|
|
378
|
+
inString = true;
|
|
379
|
+
stringQuote = ch;
|
|
380
|
+
i++;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (ch === "/" && next === "/") {
|
|
384
|
+
while (i < src.length && src[i] !== "\n") i++;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (ch === "/" && next === "*") {
|
|
388
|
+
i += 2;
|
|
389
|
+
while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++;
|
|
390
|
+
i += 2;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (ch === "{") depth++;
|
|
394
|
+
else if (ch === "}") {
|
|
395
|
+
depth--;
|
|
396
|
+
if (depth === 0) return i;
|
|
397
|
+
}
|
|
398
|
+
i++;
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// Block rendering
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
function renderObservabilityBlock(opts: CliOpts, indent = " "): string {
|
|
408
|
+
const persist = opts.persist;
|
|
409
|
+
return [
|
|
410
|
+
`"observability": {`,
|
|
411
|
+
`${indent}// Cloudflare ships console.* output OTLP-encoded to the`,
|
|
412
|
+
`${indent}// HyperDX destination provisioned at the account level. No`,
|
|
413
|
+
`${indent}// in-Worker exporter, no flush bug, no subrequest cost.`,
|
|
414
|
+
`${indent}"logs": {`,
|
|
415
|
+
`${indent}${indent}"enabled": true,`,
|
|
416
|
+
`${indent}${indent}"invocation_logs": true,`,
|
|
417
|
+
`${indent}${indent}"head_sampling_rate": ${opts.logsRate},`,
|
|
418
|
+
`${indent}${indent}"persist": ${persist},`,
|
|
419
|
+
`${indent}${indent}"destinations": ["${opts.logsDest}"]`,
|
|
420
|
+
`${indent}},`,
|
|
421
|
+
`${indent}// Auto-instruments fetch/KV/R2/DO + picks up @opentelemetry/api`,
|
|
422
|
+
`${indent}// global tracer spans (the bridge instrumentWorker installs).`,
|
|
423
|
+
`${indent}// Sampling is one global rate per Worker; URL-pattern sampling`,
|
|
424
|
+
`${indent}// requires opting back into the URLBasedSampler escape hatch.`,
|
|
425
|
+
`${indent}"traces": {`,
|
|
426
|
+
`${indent}${indent}"enabled": true,`,
|
|
427
|
+
`${indent}${indent}"head_sampling_rate": ${opts.tracesRate},`,
|
|
428
|
+
`${indent}${indent}"persist": ${persist},`,
|
|
429
|
+
`${indent}${indent}"destinations": ["${opts.tracesDest}"]`,
|
|
430
|
+
`${indent}}`,
|
|
431
|
+
`}`,
|
|
432
|
+
].join("\n");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// Detect "already CF-native"
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
function isAlreadyCfNative(src: string, opts: CliOpts): boolean {
|
|
440
|
+
// Cheap heuristic: the file mentions both destinations (under either
|
|
441
|
+
// logs or traces) and a `head_sampling_rate`. A more thorough parse
|
|
442
|
+
// is overkill for an idempotency check.
|
|
443
|
+
if (!src.includes(`"destinations"`)) return false;
|
|
444
|
+
if (!src.includes(opts.logsDest) && !src.includes(opts.tracesDest)) return false;
|
|
445
|
+
if (!src.includes("head_sampling_rate")) return false;
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// Validation
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
function validateJson(src: string): { ok: true } | { ok: false; error: string } {
|
|
454
|
+
try {
|
|
455
|
+
JSON.parse(stripJsoncComments(src));
|
|
456
|
+
return { ok: true };
|
|
457
|
+
} catch (e) {
|
|
458
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
// Diff (tiny line-level)
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
function unifiedDiff(before: string, after: string, file: string): string {
|
|
467
|
+
const a = before.split("\n");
|
|
468
|
+
const b = after.split("\n");
|
|
469
|
+
// Find the changed window — it's always contiguous because we only edit
|
|
470
|
+
// one block. Keep it brain-simple: shrink both ends, print the rest with
|
|
471
|
+
// -/+ markers.
|
|
472
|
+
let i = 0;
|
|
473
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
474
|
+
let endA = a.length;
|
|
475
|
+
let endB = b.length;
|
|
476
|
+
while (endA > i && endB > i && a[endA - 1] === b[endB - 1]) {
|
|
477
|
+
endA--;
|
|
478
|
+
endB--;
|
|
479
|
+
}
|
|
480
|
+
const ctxStart = Math.max(0, i - 3);
|
|
481
|
+
const ctxAEnd = Math.min(a.length, endA + 3);
|
|
482
|
+
const ctxBEnd = Math.min(b.length, endB + 3);
|
|
483
|
+
const lines: string[] = [];
|
|
484
|
+
lines.push(`--- ${file} (before)`);
|
|
485
|
+
lines.push(`+++ ${file} (after)`);
|
|
486
|
+
for (let k = ctxStart; k < i; k++) lines.push(` ${a[k]}`);
|
|
487
|
+
for (let k = i; k < endA; k++) lines.push(`- ${a[k]}`);
|
|
488
|
+
for (let k = i; k < endB; k++) lines.push(`+ ${b[k]}`);
|
|
489
|
+
for (let k = endA; k < ctxAEnd; k++) lines.push(` ${a[k]}`);
|
|
490
|
+
// ctxBEnd guards equality at the tail; printing either tail context is fine.
|
|
491
|
+
void ctxBEnd;
|
|
492
|
+
return lines.join("\n");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
// Main
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Detect the column-0..keyStart whitespace of the line containing
|
|
501
|
+
* `keyStart`, so we can re-indent every line of the rendered block to
|
|
502
|
+
* match the file's existing nesting depth.
|
|
503
|
+
*/
|
|
504
|
+
function detectLineIndent(src: string, position: number): string {
|
|
505
|
+
let lineStart = position;
|
|
506
|
+
while (lineStart > 0 && src[lineStart - 1] !== "\n") lineStart--;
|
|
507
|
+
let i = lineStart;
|
|
508
|
+
while (i < src.length && (src[i] === " " || src[i] === "\t")) i++;
|
|
509
|
+
return src.slice(lineStart, i);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function reindentBlockBody(block: string, indent: string): string {
|
|
513
|
+
const lines = block.split("\n");
|
|
514
|
+
// Leave line 0 alone — it's spliced in at the existing key position
|
|
515
|
+
// which is already indented by surrounding text. Re-indent the rest.
|
|
516
|
+
return [lines[0], ...lines.slice(1).map((l) => indent + l)].join("\n");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function applyEdit(src: string, opts: CliOpts): string {
|
|
520
|
+
const block = renderObservabilityBlock(opts);
|
|
521
|
+
const range = findTopLevelKeyRange(src, "observability");
|
|
522
|
+
|
|
523
|
+
if (range) {
|
|
524
|
+
// Replace the existing `"observability": {...}` (key + value) with the
|
|
525
|
+
// new block. Re-indent body lines to match the existing key's column
|
|
526
|
+
// so the final wrangler.jsonc stays cleanly formatted.
|
|
527
|
+
const indent = detectLineIndent(src, range.keyStart);
|
|
528
|
+
const indentedBlock = reindentBlockBody(block, indent);
|
|
529
|
+
return src.slice(0, range.keyStart) + indentedBlock + src.slice(range.valueEnd);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// No observability key — append before the closing `}` of the top-level object.
|
|
533
|
+
const end = findTopLevelObjectEnd(src);
|
|
534
|
+
if (end == null) {
|
|
535
|
+
throw new Error("wrangler.jsonc: could not locate top-level closing `}`");
|
|
536
|
+
}
|
|
537
|
+
// Determine if we need a leading comma on the new key.
|
|
538
|
+
const insertAt = end;
|
|
539
|
+
let scan = end - 1;
|
|
540
|
+
while (scan >= 0 && /\s/.test(src[scan])) scan--;
|
|
541
|
+
const prevChar = scan >= 0 ? src[scan] : "";
|
|
542
|
+
const needsComma = prevChar !== "{" && prevChar !== ",";
|
|
543
|
+
const baseIndent = " ";
|
|
544
|
+
const indented = block
|
|
545
|
+
.split("\n")
|
|
546
|
+
.map((l) => baseIndent + l)
|
|
547
|
+
.join("\n");
|
|
548
|
+
const insertion = `${needsComma ? "," : ""}\n${indented}\n`;
|
|
549
|
+
return src.slice(0, insertAt) + insertion + src.slice(insertAt);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function main(): void {
|
|
553
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
554
|
+
if (opts.help) {
|
|
555
|
+
showHelp();
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const wranglerPath = path.join(path.resolve(opts.source), "wrangler.jsonc");
|
|
559
|
+
if (!fs.existsSync(wranglerPath)) {
|
|
560
|
+
console.error(`error: ${wranglerPath} does not exist`);
|
|
561
|
+
process.exit(2);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const before = fs.readFileSync(wranglerPath, "utf8");
|
|
565
|
+
|
|
566
|
+
if (isAlreadyCfNative(before, opts)) {
|
|
567
|
+
console.log(`✓ ${wranglerPath} already on CF-native observability — no change.`);
|
|
568
|
+
process.exit(0);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
let after: string;
|
|
572
|
+
try {
|
|
573
|
+
after = applyEdit(before, opts);
|
|
574
|
+
} catch (e) {
|
|
575
|
+
console.error(`error: ${e instanceof Error ? e.message : String(e)}`);
|
|
576
|
+
process.exit(2);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const validation = validateJson(after);
|
|
580
|
+
if (!validation.ok) {
|
|
581
|
+
console.error(`error: result wouldn't parse as JSONC: ${validation.error}`);
|
|
582
|
+
console.error("aborting; no changes written.");
|
|
583
|
+
process.exit(2);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (!opts.write) {
|
|
587
|
+
console.log(unifiedDiff(before, after, wranglerPath));
|
|
588
|
+
console.log("\nDry-run only. Re-run with --write to apply.");
|
|
589
|
+
process.exit(1);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
fs.writeFileSync(wranglerPath, after, "utf8");
|
|
593
|
+
console.log(`✓ wrote ${wranglerPath}`);
|
|
594
|
+
console.log(`
|
|
595
|
+
Next steps:
|
|
596
|
+
1. (one-time per CF account) provision destinations in the dashboard:
|
|
597
|
+
Logs: ${opts.logsDest} → HyperDX OTLP /v1/logs + Authorization header
|
|
598
|
+
Traces: ${opts.tracesDest} → HyperDX OTLP /v1/traces + Authorization header
|
|
599
|
+
2. wrangler deploy
|
|
600
|
+
3. Verify in HyperDX:
|
|
601
|
+
service:<your-site-name> AND SeverityNumber:* → log records arriving
|
|
602
|
+
service:<your-site-name> AND duration:* → spans arriving
|
|
603
|
+
4. Delete now-orphaned secrets:
|
|
604
|
+
wrangler secret delete OTEL_EXPORTER_OTLP_ENDPOINT \\
|
|
605
|
+
OTEL_EXPORTER_OTLP_HEADERS \\
|
|
606
|
+
OTEL_SAMPLING_CONFIG \\
|
|
607
|
+
OTEL_LOG_MIN_SEVERITY
|
|
608
|
+
`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
main();
|
package/src/sdk/logger.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
_getLoggerAttributeFloorForTests,
|
|
3
4
|
configureLogger,
|
|
4
5
|
defaultLoggerAdapter,
|
|
5
6
|
getLoggerAdapter,
|
|
@@ -7,6 +8,7 @@ import {
|
|
|
7
8
|
type LoggerAdapter,
|
|
8
9
|
logger,
|
|
9
10
|
serializeError,
|
|
11
|
+
setLoggerAttributeFloor,
|
|
10
12
|
setLogLevel,
|
|
11
13
|
} from "./logger";
|
|
12
14
|
|
|
@@ -135,6 +137,83 @@ describe("configureLogger", () => {
|
|
|
135
137
|
});
|
|
136
138
|
});
|
|
137
139
|
|
|
140
|
+
describe("setLoggerAttributeFloor", () => {
|
|
141
|
+
afterEach(() => {
|
|
142
|
+
setLoggerAttributeFloor({});
|
|
143
|
+
configureLogger(defaultLoggerAdapter);
|
|
144
|
+
setLogLevel("info");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("starts empty so the floor is a no-op out of the box", () => {
|
|
148
|
+
expect(_getLoggerAttributeFloorForTests()).toEqual({});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("merges floor attrs into every log call", () => {
|
|
152
|
+
setLoggerAttributeFloor({
|
|
153
|
+
"deco.runtime.version": "4.4.0",
|
|
154
|
+
"deployment.environment": "production",
|
|
155
|
+
});
|
|
156
|
+
const calls: Array<Record<string, unknown> | undefined> = [];
|
|
157
|
+
configureLogger({
|
|
158
|
+
log(_level, _msg, attrs) {
|
|
159
|
+
calls.push(attrs);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
logger.info("ping", { route: "/" });
|
|
164
|
+
|
|
165
|
+
expect(calls).toHaveLength(1);
|
|
166
|
+
expect(calls[0]).toEqual({
|
|
167
|
+
"deco.runtime.version": "4.4.0",
|
|
168
|
+
"deployment.environment": "production",
|
|
169
|
+
route: "/",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("lets caller attrs override the floor on key collision", () => {
|
|
174
|
+
setLoggerAttributeFloor({ "deployment.environment": "production" });
|
|
175
|
+
const calls: Array<Record<string, unknown> | undefined> = [];
|
|
176
|
+
configureLogger({
|
|
177
|
+
log(_level, _msg, attrs) {
|
|
178
|
+
calls.push(attrs);
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
logger.warn("override", { "deployment.environment": "staging" });
|
|
183
|
+
|
|
184
|
+
expect(calls[0]?.["deployment.environment"]).toBe("staging");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("applies the floor even when the caller passes no attrs", () => {
|
|
188
|
+
setLoggerAttributeFloor({ tenant: "lebiscuit" });
|
|
189
|
+
const calls: Array<Record<string, unknown> | undefined> = [];
|
|
190
|
+
configureLogger({
|
|
191
|
+
log(_level, _msg, attrs) {
|
|
192
|
+
calls.push(attrs);
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
logger.info("no-attrs");
|
|
197
|
+
|
|
198
|
+
expect(calls[0]).toEqual({ tenant: "lebiscuit" });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("clearing the floor restores the no-op fast path", () => {
|
|
202
|
+
setLoggerAttributeFloor({ tenant: "lebiscuit" });
|
|
203
|
+
setLoggerAttributeFloor({});
|
|
204
|
+
const calls: Array<Record<string, unknown> | undefined> = [];
|
|
205
|
+
configureLogger({
|
|
206
|
+
log(_level, _msg, attrs) {
|
|
207
|
+
calls.push(attrs);
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
logger.info("clean", { x: 1 });
|
|
212
|
+
|
|
213
|
+
expect(calls[0]).toEqual({ x: 1 });
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
138
217
|
describe("serializeError", () => {
|
|
139
218
|
it("flattens an Error into a JSON-safe shape with stack", () => {
|
|
140
219
|
const err = new Error("boom");
|