@decocms/start 6.0.1 → 6.2.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/MIGRATION_TOOLING_PLAN.md +9 -0
- package/docs/observability.md +20 -10
- package/docs/rum-plan.md +209 -0
- package/docs/runbooks/README.md +40 -0
- package/docs/runbooks/cache-hit-drop.md +83 -0
- package/docs/runbooks/commerce-upstream-slow.md +88 -0
- package/docs/runbooks/http-error-spike.md +98 -0
- package/docs/runbooks/http-latency-spike.md +82 -0
- package/docs/runbooks/tail-exception-spike.md +100 -0
- package/package.json +1 -1
- package/scripts/audit-observability-config.test.ts +251 -1
- package/scripts/audit-observability-config.ts +227 -26
- package/src/middleware/observability.test.ts +237 -0
- package/src/middleware/observability.ts +165 -8
- package/src/sdk/cachedLoader.ts +10 -7
- package/src/sdk/logger.test.ts +99 -0
- package/src/sdk/logger.ts +18 -7
- package/src/sdk/observability.ts +18 -0
- package/src/sdk/otel.ts +228 -38
- package/src/sdk/otelHttpTracer.test.ts +422 -0
- package/src/sdk/otelHttpTracer.ts +489 -0
- package/src/sdk/requestContext.ts +46 -0
- package/src/sdk/workerEntry.ts +138 -17
|
@@ -15,27 +15,45 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Rules (id — severity — what it catches):
|
|
17
17
|
*
|
|
18
|
-
* observability_missing
|
|
19
|
-
* observability_disabled
|
|
20
|
-
* traces_disabled
|
|
21
|
-
* logs_disabled
|
|
22
|
-
* head_sampling_rate_elevated
|
|
23
|
-
* logs_head_sampling_rate_low
|
|
24
|
-
* persist_disabled_no_destination
|
|
18
|
+
* observability_missing error No `observability` key at all. CF captures nothing.
|
|
19
|
+
* observability_disabled error `observability.enabled: false`. Master switch off.
|
|
20
|
+
* traces_disabled warn `observability.traces.enabled: false`. No traces in dashboard.
|
|
21
|
+
* logs_disabled warn `observability.logs.enabled: false`. No logs in dashboard.
|
|
22
|
+
* head_sampling_rate_elevated error `traces.head_sampling_rate > 0.01`. Fleet-scale cost risk; see docs/observability.md.
|
|
23
|
+
* 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.
|
|
24
|
+
* persist_disabled_no_destination error `persist: false` with no destination configured. Data captured then discarded.
|
|
25
|
+
*
|
|
26
|
+
* Phase 6 / D-14 — fleet-config drift rules (live outside the
|
|
27
|
+
* `observability` block but still owned by this audit):
|
|
28
|
+
*
|
|
29
|
+
* version_metadata_binding_missing error Missing `version_metadata` binding. `service.version` won't be stamped — regressions can't be attributed to a deploy.
|
|
30
|
+
* analytics_engine_binding_missing warn No `DECO_METRICS` AE binding. AE meter is off; OTLP meter still works but CF dashboard panels go dark.
|
|
31
|
+
* tail_consumer_missing error No `tail_consumers` entry pointing at `deco-otel-tail`. 100% error-capture is broken.
|
|
32
|
+
* otel_metrics_endpoint_missing warn `DECO_OTEL_METRICS_ENDPOINT` not set on `vars`. OTLP meter is off; only AE works.
|
|
33
|
+
* otel_traces_endpoint_missing warn `DECO_OTEL_TRACES_ENDPOINT` not set on `vars`. Framework `deco.*` spans drop unless CF Traces is on.
|
|
34
|
+
* otel_logs_endpoint_missing warn `DECO_OTEL_LOGS_ENDPOINT` not set on `vars`. Error logs ride CF Destinations only (head-sampled).
|
|
25
35
|
*
|
|
26
36
|
* Usage (from a site directory):
|
|
27
|
-
* npx -p @decocms/start deco-audit-observability # audit cwd
|
|
28
|
-
* npx -p @decocms/start deco-audit-observability --source ./ # explicit
|
|
37
|
+
* npx -p @decocms/start deco-audit-observability # audit cwd (warn mode — exit 0)
|
|
38
|
+
* npx -p @decocms/start deco-audit-observability --source ./ # explicit source dir
|
|
29
39
|
* npx -p @decocms/start deco-audit-observability --json # machine-readable
|
|
40
|
+
* npx -p @decocms/start deco-audit-observability --mode block # error findings exit 1 (CI gate)
|
|
41
|
+
* npx -p @decocms/start deco-audit-observability --github # GitHub Actions annotations
|
|
30
42
|
*
|
|
31
43
|
* Options:
|
|
32
44
|
* --source <dir> Site directory (default: .)
|
|
33
|
-
* --json Emit findings as JSON to stdout
|
|
45
|
+
* --json Emit findings as JSON to stdout
|
|
46
|
+
* --mode <m> Gate hardness: "warn" (default — always exit 0 on findings,
|
|
47
|
+
* just print them) or "block" (exit 1 on any `error` finding).
|
|
48
|
+
* See D-16 in MIGRATION_TOOLING_PLAN.md for the rationale on
|
|
49
|
+
* why warn is the v1 default.
|
|
50
|
+
* --github Emit `::warning::` / `::error::` lines for GitHub Actions
|
|
51
|
+
* annotations in addition to the normal text output.
|
|
34
52
|
* --help, -h Show this message
|
|
35
53
|
*
|
|
36
54
|
* Exit codes:
|
|
37
|
-
* 0 — no findings
|
|
38
|
-
* 1 — at least one finding
|
|
55
|
+
* 0 — no findings, or `--mode warn` (the default) regardless of findings
|
|
56
|
+
* 1 — `--mode block` and at least one `error`-severity finding
|
|
39
57
|
* 2 — file invalid / can't parse
|
|
40
58
|
*/
|
|
41
59
|
|
|
@@ -53,14 +71,24 @@ export interface Finding {
|
|
|
53
71
|
fix?: string;
|
|
54
72
|
}
|
|
55
73
|
|
|
74
|
+
export type GateMode = "warn" | "block";
|
|
75
|
+
|
|
56
76
|
interface CliOpts {
|
|
57
77
|
source: string;
|
|
58
78
|
json: boolean;
|
|
59
79
|
help: boolean;
|
|
80
|
+
mode: GateMode;
|
|
81
|
+
github: boolean;
|
|
60
82
|
}
|
|
61
83
|
|
|
62
84
|
function parseArgs(argv: string[]): CliOpts {
|
|
63
|
-
const opts: CliOpts = {
|
|
85
|
+
const opts: CliOpts = {
|
|
86
|
+
source: ".",
|
|
87
|
+
json: false,
|
|
88
|
+
help: false,
|
|
89
|
+
mode: "warn",
|
|
90
|
+
github: false,
|
|
91
|
+
};
|
|
64
92
|
for (let i = 0; i < argv.length; i++) {
|
|
65
93
|
const flag = argv[i];
|
|
66
94
|
switch (flag) {
|
|
@@ -70,6 +98,20 @@ function parseArgs(argv: string[]): CliOpts {
|
|
|
70
98
|
case "--json":
|
|
71
99
|
opts.json = true;
|
|
72
100
|
break;
|
|
101
|
+
case "--mode": {
|
|
102
|
+
const value = argv[++i];
|
|
103
|
+
if (value !== "warn" && value !== "block") {
|
|
104
|
+
console.error(
|
|
105
|
+
`audit: --mode must be "warn" or "block" (got "${value ?? ""}")`,
|
|
106
|
+
);
|
|
107
|
+
process.exit(2);
|
|
108
|
+
}
|
|
109
|
+
opts.mode = value;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "--github":
|
|
113
|
+
opts.github = true;
|
|
114
|
+
break;
|
|
73
115
|
case "--help":
|
|
74
116
|
case "-h":
|
|
75
117
|
opts.help = true;
|
|
@@ -91,14 +133,18 @@ function showHelp(): void {
|
|
|
91
133
|
npx -p @decocms/start deco-audit-observability [options]
|
|
92
134
|
|
|
93
135
|
Options:
|
|
94
|
-
--source <dir>
|
|
95
|
-
--json
|
|
96
|
-
--
|
|
136
|
+
--source <dir> Site directory (default: .)
|
|
137
|
+
--json Emit findings as JSON
|
|
138
|
+
--mode <m> "warn" (default, exit 0) | "block" (exit 1 on errors)
|
|
139
|
+
--github Emit ::warning::/::error:: lines for GitHub Actions
|
|
140
|
+
--help, -h This message
|
|
97
141
|
|
|
98
142
|
Exit codes:
|
|
99
|
-
0 no findings
|
|
100
|
-
1
|
|
143
|
+
0 no findings, OR --mode warn (default — annotate and move on)
|
|
144
|
+
1 --mode block AND at least one error-severity finding
|
|
101
145
|
2 wrangler.jsonc missing or unparseable
|
|
146
|
+
|
|
147
|
+
See D-16 in MIGRATION_TOOLING_PLAN.md for the v1 "warn-only" policy.
|
|
102
148
|
`);
|
|
103
149
|
}
|
|
104
150
|
|
|
@@ -236,6 +282,138 @@ export function auditObservabilityBlock(
|
|
|
236
282
|
return findings;
|
|
237
283
|
}
|
|
238
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Fleet-config drift rules — owned by the same audit because the
|
|
287
|
+
* cumulative effect of "observability block correct, bindings missing"
|
|
288
|
+
* is identical to "observability block missing" (no data lands in
|
|
289
|
+
* ClickHouse).
|
|
290
|
+
*
|
|
291
|
+
* The CLI composes `auditObservabilityBlock` + `auditFleetBindings`.
|
|
292
|
+
* Both return Finding[]; callers concatenate.
|
|
293
|
+
*/
|
|
294
|
+
export interface WranglerLike {
|
|
295
|
+
observability?: ObservabilityBlock;
|
|
296
|
+
version_metadata?: { binding?: string } | unknown;
|
|
297
|
+
analytics_engine_datasets?: Array<{ binding?: string; dataset?: string }> | unknown;
|
|
298
|
+
tail_consumers?: Array<{ service?: string; environment?: string }> | unknown;
|
|
299
|
+
vars?: Record<string, unknown> | unknown;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function auditFleetBindings(wrangler: WranglerLike): Finding[] {
|
|
303
|
+
const findings: Finding[] = [];
|
|
304
|
+
|
|
305
|
+
// version_metadata — required so `service.version` is stamped on every
|
|
306
|
+
// span and log line. Without it, regressions can't be tied to a
|
|
307
|
+
// specific deployment.
|
|
308
|
+
const vmBinding =
|
|
309
|
+
typeof wrangler.version_metadata === "object" &&
|
|
310
|
+
wrangler.version_metadata !== null &&
|
|
311
|
+
"binding" in wrangler.version_metadata
|
|
312
|
+
? (wrangler.version_metadata as { binding?: string }).binding
|
|
313
|
+
: undefined;
|
|
314
|
+
if (!vmBinding) {
|
|
315
|
+
findings.push({
|
|
316
|
+
id: "version_metadata_binding_missing",
|
|
317
|
+
severity: "error",
|
|
318
|
+
message:
|
|
319
|
+
"wrangler.jsonc is missing a `version_metadata.binding` entry. " +
|
|
320
|
+
"`service.version` won't appear on spans/logs and the deploy-correlation " +
|
|
321
|
+
"panel will be empty. Recommended value: `CF_VERSION_METADATA`.",
|
|
322
|
+
fix: "npx -p @decocms/start deco-cf-observability --write",
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// DECO_METRICS — Analytics Engine binding. The AE meter is the hot-
|
|
327
|
+
// path CF dashboard view; OTLP works without it but we lose the
|
|
328
|
+
// operator-grade short-window panels.
|
|
329
|
+
const aeDatasets = Array.isArray(wrangler.analytics_engine_datasets)
|
|
330
|
+
? (wrangler.analytics_engine_datasets as Array<{ binding?: string }>)
|
|
331
|
+
: [];
|
|
332
|
+
const hasMetricsBinding = aeDatasets.some((d) => d?.binding === "DECO_METRICS");
|
|
333
|
+
if (!hasMetricsBinding) {
|
|
334
|
+
findings.push({
|
|
335
|
+
id: "analytics_engine_binding_missing",
|
|
336
|
+
severity: "warn",
|
|
337
|
+
message:
|
|
338
|
+
"wrangler.jsonc has no `analytics_engine_datasets[].binding == 'DECO_METRICS'`. " +
|
|
339
|
+
"The AE meter is off; the hot-path operator dashboards in CF will be empty. " +
|
|
340
|
+
"OTLP metrics keep flowing if `DECO_OTEL_METRICS_ENDPOINT` is set.",
|
|
341
|
+
fix: "npx -p @decocms/start deco-cf-observability --write",
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// tail_consumers — must list deco-otel-tail. Phase 1 enrichment is
|
|
346
|
+
// useless without the tail consumer firing.
|
|
347
|
+
const tail = Array.isArray(wrangler.tail_consumers)
|
|
348
|
+
? (wrangler.tail_consumers as Array<{ service?: string }>)
|
|
349
|
+
: [];
|
|
350
|
+
const hasTailConsumer = tail.some((t) => t?.service === "deco-otel-tail");
|
|
351
|
+
if (!hasTailConsumer) {
|
|
352
|
+
findings.push({
|
|
353
|
+
id: "tail_consumer_missing",
|
|
354
|
+
severity: "error",
|
|
355
|
+
message:
|
|
356
|
+
"wrangler.jsonc has no `tail_consumers[].service == 'deco-otel-tail'` entry. " +
|
|
357
|
+
"100% error-capture is broken — only the head-sampled CF Destinations path " +
|
|
358
|
+
"will report errors, and isolate crashes will be invisible.",
|
|
359
|
+
fix: "npx -p @decocms/start deco-cf-observability --write",
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// DECO_OTEL_*_ENDPOINT env vars. Audit each separately so the message
|
|
364
|
+
// explains which channel is silently no-op.
|
|
365
|
+
const vars =
|
|
366
|
+
typeof wrangler.vars === "object" && wrangler.vars !== null
|
|
367
|
+
? (wrangler.vars as Record<string, unknown>)
|
|
368
|
+
: {};
|
|
369
|
+
const checkVar = (id: string, name: string, severity: Severity, channel: string) => {
|
|
370
|
+
const v = vars[name];
|
|
371
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
372
|
+
findings.push({
|
|
373
|
+
id,
|
|
374
|
+
severity,
|
|
375
|
+
message:
|
|
376
|
+
`wrangler.jsonc \`vars.${name}\` is not set. ${channel} is a no-op; ` +
|
|
377
|
+
`data captured in this channel never lands in ClickHouse. ` +
|
|
378
|
+
`See docs/observability.md for the canonical endpoints.`,
|
|
379
|
+
fix: "npx -p @decocms/start deco-cf-observability --write",
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
checkVar(
|
|
384
|
+
"otel_metrics_endpoint_missing",
|
|
385
|
+
"DECO_OTEL_METRICS_ENDPOINT",
|
|
386
|
+
"warn",
|
|
387
|
+
"OTLP metrics direct-POST",
|
|
388
|
+
);
|
|
389
|
+
checkVar(
|
|
390
|
+
"otel_traces_endpoint_missing",
|
|
391
|
+
"DECO_OTEL_TRACES_ENDPOINT",
|
|
392
|
+
"warn",
|
|
393
|
+
"OTLP traces direct-POST",
|
|
394
|
+
);
|
|
395
|
+
checkVar(
|
|
396
|
+
"otel_logs_endpoint_missing",
|
|
397
|
+
"DECO_OTEL_LOGS_ENDPOINT",
|
|
398
|
+
"warn",
|
|
399
|
+
"OTLP error-log direct-POST",
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
return findings;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* One-stop call for the full wrangler audit — composes the
|
|
407
|
+
* observability-block rules with the fleet-binding rules. Both keep
|
|
408
|
+
* working standalone for fine-grained tests.
|
|
409
|
+
*/
|
|
410
|
+
export function auditWranglerConfig(wrangler: WranglerLike): Finding[] {
|
|
411
|
+
return [
|
|
412
|
+
...auditObservabilityBlock(wrangler.observability),
|
|
413
|
+
...auditFleetBindings(wrangler),
|
|
414
|
+
];
|
|
415
|
+
}
|
|
416
|
+
|
|
239
417
|
function findingsToText(file: string, findings: Finding[]): string {
|
|
240
418
|
if (findings.length === 0) {
|
|
241
419
|
return `OK ${file} — observability config looks canonical`;
|
|
@@ -263,26 +441,49 @@ function main(): void {
|
|
|
263
441
|
process.exit(2);
|
|
264
442
|
}
|
|
265
443
|
|
|
266
|
-
let parsed:
|
|
444
|
+
let parsed: WranglerLike;
|
|
267
445
|
try {
|
|
268
|
-
parsed = parseJsonc(fs.readFileSync(file, "utf8"));
|
|
446
|
+
parsed = parseJsonc(fs.readFileSync(file, "utf8")) as WranglerLike;
|
|
269
447
|
} catch (err) {
|
|
270
448
|
console.error(`audit: ${file} could not be parsed: ${(err as Error).message}`);
|
|
271
449
|
process.exit(2);
|
|
272
450
|
}
|
|
273
451
|
|
|
274
|
-
const findings =
|
|
452
|
+
const findings = auditWranglerConfig(parsed);
|
|
275
453
|
|
|
276
454
|
if (opts.json) {
|
|
277
|
-
process.stdout.write(
|
|
455
|
+
process.stdout.write(
|
|
456
|
+
JSON.stringify({ file, mode: opts.mode, findings }, null, 2) + "\n",
|
|
457
|
+
);
|
|
278
458
|
} else {
|
|
279
459
|
process.stdout.write(findingsToText(file, findings) + "\n");
|
|
280
460
|
}
|
|
281
461
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
462
|
+
if (opts.github) {
|
|
463
|
+
for (const f of findings) {
|
|
464
|
+
// GitHub Actions workflow command. `error` and `warning` annotate the
|
|
465
|
+
// diff; `notice` is informational. We never emit `error` in warn mode
|
|
466
|
+
// even for error-severity rules — the v1 policy is annotate-don't-fail.
|
|
467
|
+
const level = opts.mode === "block" && f.severity === "error"
|
|
468
|
+
? "error"
|
|
469
|
+
: f.severity === "info" ? "notice" : "warning";
|
|
470
|
+
const msg = `${f.message}${f.fix ? ` (fix: ${f.fix})` : ""}`;
|
|
471
|
+
const escaped = msg.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(
|
|
472
|
+
/\n/g,
|
|
473
|
+
"%0A",
|
|
474
|
+
);
|
|
475
|
+
process.stdout.write(`::${level} title=${f.id}::${escaped}\n`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Exit policy: D-16 / Phase 6 decision.
|
|
480
|
+
// warn — annotate only; always exit 0 (CI sees the findings but ships)
|
|
481
|
+
// block — exit 1 on any `error`-severity finding
|
|
482
|
+
// The default is `warn` because storefronts are upgraded over weeks; a
|
|
483
|
+
// day-one block would fail PRs that have nothing to do with observability.
|
|
484
|
+
const shouldFail = opts.mode === "block" &&
|
|
485
|
+
findings.some((f) => f.severity === "error");
|
|
486
|
+
process.exit(shouldFail ? 1 : 0);
|
|
286
487
|
}
|
|
287
488
|
|
|
288
489
|
// Only run when invoked directly, not when imported by tests.
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2 (D-11) coverage for the metric surface — canonical label set,
|
|
3
|
+
* cache_layer, commerce_request_duration_ms. The Phase 1 logger/trace
|
|
4
|
+
* tests live under `src/sdk/logger.test.ts` and `src/sdk/otel.test.ts`;
|
|
5
|
+
* this file focuses on the middleware-level helpers.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
configureMeter,
|
|
10
|
+
type MeterAdapter,
|
|
11
|
+
MetricNames,
|
|
12
|
+
recordCacheMetric,
|
|
13
|
+
recordCommerceMetric,
|
|
14
|
+
recordRequestMetric,
|
|
15
|
+
statusClassFor,
|
|
16
|
+
} from "./observability";
|
|
17
|
+
|
|
18
|
+
interface Counter {
|
|
19
|
+
name: string;
|
|
20
|
+
value: number;
|
|
21
|
+
labels?: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
interface Histogram {
|
|
24
|
+
name: string;
|
|
25
|
+
value: number;
|
|
26
|
+
labels?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function captureMeter(): {
|
|
30
|
+
adapter: MeterAdapter;
|
|
31
|
+
counters: Counter[];
|
|
32
|
+
histograms: Histogram[];
|
|
33
|
+
} {
|
|
34
|
+
const counters: Counter[] = [];
|
|
35
|
+
const histograms: Histogram[] = [];
|
|
36
|
+
const adapter: MeterAdapter = {
|
|
37
|
+
counterInc(name, value, labels) {
|
|
38
|
+
counters.push({ name, value: value ?? 1, labels });
|
|
39
|
+
},
|
|
40
|
+
histogramRecord(name, value, labels) {
|
|
41
|
+
histograms.push({ name, value, labels });
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
return { adapter, counters, histograms };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("statusClassFor", () => {
|
|
48
|
+
it("maps 2xx / 3xx / 4xx / 5xx to canonical class labels", () => {
|
|
49
|
+
expect(statusClassFor(200)).toBe("2xx");
|
|
50
|
+
expect(statusClassFor(204)).toBe("2xx");
|
|
51
|
+
expect(statusClassFor(301)).toBe("3xx");
|
|
52
|
+
expect(statusClassFor(404)).toBe("4xx");
|
|
53
|
+
expect(statusClassFor(500)).toBe("5xx");
|
|
54
|
+
expect(statusClassFor(503)).toBe("5xx");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns 'unknown' for out-of-range / NaN / non-numeric inputs", () => {
|
|
58
|
+
expect(statusClassFor(-1)).toBe("unknown");
|
|
59
|
+
expect(statusClassFor(99)).toBe("unknown");
|
|
60
|
+
expect(statusClassFor(600)).toBe("unknown");
|
|
61
|
+
expect(statusClassFor(Number.NaN)).toBe("unknown");
|
|
62
|
+
expect(statusClassFor(Infinity)).toBe("unknown");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("recordRequestMetric — canonical labels (D-11)", () => {
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
// Reset meter so other tests start clean.
|
|
69
|
+
configureMeter({ counterInc: () => {} });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("stamps method + route_pattern + status + status_class by default", () => {
|
|
73
|
+
const { adapter, counters, histograms } = captureMeter();
|
|
74
|
+
configureMeter(adapter);
|
|
75
|
+
|
|
76
|
+
recordRequestMetric("GET", "/products/abc123/p", 200, 42);
|
|
77
|
+
|
|
78
|
+
expect(counters).toHaveLength(1);
|
|
79
|
+
expect(counters[0]?.name).toBe(MetricNames.HTTP_REQUESTS_TOTAL);
|
|
80
|
+
expect(counters[0]?.labels).toMatchObject({
|
|
81
|
+
method: "GET",
|
|
82
|
+
// Default normalization: dynamic segments collapsed.
|
|
83
|
+
route_pattern: "/products/:slug/p",
|
|
84
|
+
status: 200,
|
|
85
|
+
status_class: "2xx",
|
|
86
|
+
});
|
|
87
|
+
expect(histograms).toHaveLength(1);
|
|
88
|
+
expect(histograms[0]?.name).toBe(MetricNames.HTTP_REQUEST_DURATION_MS);
|
|
89
|
+
expect(histograms[0]?.value).toBe(42);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("prefers caller-supplied route_pattern over normalized path", () => {
|
|
93
|
+
const { adapter, counters } = captureMeter();
|
|
94
|
+
configureMeter(adapter);
|
|
95
|
+
|
|
96
|
+
recordRequestMetric("GET", "/anything/random/123", 200, 5, {
|
|
97
|
+
route_pattern: "/_products/$slug/p",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(counters[0]?.labels?.route_pattern).toBe("/_products/$slug/p");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("emits http_request_errors_total on 5xx", () => {
|
|
104
|
+
const { adapter, counters } = captureMeter();
|
|
105
|
+
configureMeter(adapter);
|
|
106
|
+
|
|
107
|
+
recordRequestMetric("POST", "/checkout", 503, 120);
|
|
108
|
+
|
|
109
|
+
const errCounter = counters.find((c) => c.name === MetricNames.HTTP_REQUEST_ERRORS);
|
|
110
|
+
expect(errCounter).toBeDefined();
|
|
111
|
+
expect(errCounter?.labels?.status_class).toBe("5xx");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("propagates optional labels (outcome, cache_decision, cache_layer, region, extra)", () => {
|
|
115
|
+
const { adapter, counters } = captureMeter();
|
|
116
|
+
configureMeter(adapter);
|
|
117
|
+
|
|
118
|
+
recordRequestMetric("GET", "/", 200, 10, {
|
|
119
|
+
outcome: "ok",
|
|
120
|
+
cache_decision: "STALE-HIT",
|
|
121
|
+
cache_layer: "edge",
|
|
122
|
+
region: "GRU",
|
|
123
|
+
extra: { ab_variant: "B" },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(counters[0]?.labels).toMatchObject({
|
|
127
|
+
outcome: "ok",
|
|
128
|
+
cache_decision: "STALE-HIT",
|
|
129
|
+
cache_layer: "edge",
|
|
130
|
+
region: "GRU",
|
|
131
|
+
ab_variant: "B",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("is a no-op when no meter is configured", () => {
|
|
136
|
+
// We can't easily prove a no-op other than verifying no throw —
|
|
137
|
+
// safer than calling configureMeter(null), which would mask real
|
|
138
|
+
// bugs. The previous test's `afterEach` reset already gives us a
|
|
139
|
+
// bare meter; this test confirms the call is benign.
|
|
140
|
+
expect(() => recordRequestMetric("GET", "/", 200, 1)).not.toThrow();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("recordCacheMetric — cache_layer label", () => {
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
configureMeter({ counterInc: () => {} });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("stamps profile + decision + layer when all are provided", () => {
|
|
150
|
+
const { adapter, counters } = captureMeter();
|
|
151
|
+
configureMeter(adapter);
|
|
152
|
+
|
|
153
|
+
recordCacheMetric(true, "product", "HIT", "edge");
|
|
154
|
+
|
|
155
|
+
expect(counters).toHaveLength(1);
|
|
156
|
+
expect(counters[0]?.name).toBe(MetricNames.CACHE_HIT);
|
|
157
|
+
expect(counters[0]?.labels).toMatchObject({
|
|
158
|
+
profile: "product",
|
|
159
|
+
decision: "HIT",
|
|
160
|
+
layer: "edge",
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("emits cache_miss_total when hit=false", () => {
|
|
165
|
+
const { adapter, counters } = captureMeter();
|
|
166
|
+
configureMeter(adapter);
|
|
167
|
+
|
|
168
|
+
recordCacheMetric(false, "search", "MISS", "edge");
|
|
169
|
+
|
|
170
|
+
expect(counters[0]?.name).toBe(MetricNames.CACHE_MISS);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("supports the legacy 3-arg signature for backward compat", () => {
|
|
174
|
+
const { adapter, counters } = captureMeter();
|
|
175
|
+
configureMeter(adapter);
|
|
176
|
+
|
|
177
|
+
recordCacheMetric(true, "static");
|
|
178
|
+
|
|
179
|
+
expect(counters[0]?.labels).toEqual({ profile: "static" });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("distinguishes cachedLoader vs edge vs vtex-swr layers", () => {
|
|
183
|
+
const { adapter, counters } = captureMeter();
|
|
184
|
+
configureMeter(adapter);
|
|
185
|
+
|
|
186
|
+
recordCacheMetric(true, "loader-x", "HIT", "cachedLoader");
|
|
187
|
+
recordCacheMetric(true, "vtex-product", "HIT", "vtex-swr");
|
|
188
|
+
|
|
189
|
+
expect(counters[0]?.labels?.layer).toBe("cachedLoader");
|
|
190
|
+
expect(counters[1]?.labels?.layer).toBe("vtex-swr");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("recordCommerceMetric (D-11)", () => {
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
configureMeter({ counterInc: () => {} });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("emits commerce_request_duration_ms with provider + operation labels", () => {
|
|
200
|
+
const { adapter, histograms } = captureMeter();
|
|
201
|
+
configureMeter(adapter);
|
|
202
|
+
|
|
203
|
+
recordCommerceMetric(123, {
|
|
204
|
+
provider: "vtex",
|
|
205
|
+
operation: "intelligent-search.product_search",
|
|
206
|
+
status_class: "2xx",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(histograms).toHaveLength(1);
|
|
210
|
+
expect(histograms[0]?.name).toBe(MetricNames.COMMERCE_REQUEST_DURATION_MS);
|
|
211
|
+
expect(histograms[0]?.value).toBe(123);
|
|
212
|
+
expect(histograms[0]?.labels).toMatchObject({
|
|
213
|
+
provider: "vtex",
|
|
214
|
+
operation: "intelligent-search.product_search",
|
|
215
|
+
status_class: "2xx",
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("includes the cached boolean when provided", () => {
|
|
220
|
+
const { adapter, histograms } = captureMeter();
|
|
221
|
+
configureMeter(adapter);
|
|
222
|
+
|
|
223
|
+
recordCommerceMetric(5, {
|
|
224
|
+
provider: "shopify",
|
|
225
|
+
operation: "graphql.cart_query",
|
|
226
|
+
cached: true,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(histograms[0]?.labels?.cached).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("is a no-op when no meter is configured", () => {
|
|
233
|
+
expect(() =>
|
|
234
|
+
recordCommerceMetric(1, { provider: "vtex", operation: "test" }),
|
|
235
|
+
).not.toThrow();
|
|
236
|
+
});
|
|
237
|
+
});
|