@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
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -39,12 +39,16 @@ import {
|
|
|
39
39
|
} from "./cacheHeaders";
|
|
40
40
|
import { buildHtmlShell } from "./htmlShell";
|
|
41
41
|
import {
|
|
42
|
+
getActiveSpan,
|
|
42
43
|
logRequest,
|
|
43
44
|
recordCacheMetric,
|
|
44
45
|
recordRequestMetric,
|
|
46
|
+
setSpanAttribute,
|
|
45
47
|
withTracing,
|
|
46
48
|
} from "./observability";
|
|
49
|
+
import { _setRequestTraceContext } from "./otel";
|
|
47
50
|
import { setRuntimeEnv } from "./otelAdapters";
|
|
51
|
+
import { parseTraceparent } from "./otelHttpTracer";
|
|
48
52
|
import { RequestContext } from "./requestContext";
|
|
49
53
|
import { cleanPathForCacheKey } from "./urlUtils";
|
|
50
54
|
import { type Device, isMobileUA } from "./useDevice";
|
|
@@ -62,6 +66,25 @@ import { isDevMode } from "./env";
|
|
|
62
66
|
*/
|
|
63
67
|
declare const __DECO_BUILD_HASH__: string | undefined;
|
|
64
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The five canonical cache-decision strings stamped on the `X-Cache`
|
|
71
|
+
* response header (and on the `decision` label of `cache_*_total`
|
|
72
|
+
* metrics). Used by the request-metric label enrichment to keep label
|
|
73
|
+
* cardinality bounded — anything else (e.g. an upstream proxy that sets
|
|
74
|
+
* its own `X-Cache: random-text`) is dropped from the label.
|
|
75
|
+
*/
|
|
76
|
+
type CacheDecisionString = "HIT" | "STALE-HIT" | "STALE-ERROR" | "MISS" | "BYPASS";
|
|
77
|
+
|
|
78
|
+
function isCacheDecision(value: string | null): value is CacheDecisionString {
|
|
79
|
+
return (
|
|
80
|
+
value === "HIT" ||
|
|
81
|
+
value === "STALE-HIT" ||
|
|
82
|
+
value === "STALE-ERROR" ||
|
|
83
|
+
value === "MISS" ||
|
|
84
|
+
value === "BYPASS"
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
/**
|
|
66
89
|
* Append Link preload headers for CSS and fonts so the browser starts
|
|
67
90
|
* fetching them before parsing HTML. Only applied to HTML responses.
|
|
@@ -1002,6 +1025,14 @@ export function createDecoWorkerEntry(
|
|
|
1002
1025
|
request = injectGeoCookies(request);
|
|
1003
1026
|
}
|
|
1004
1027
|
|
|
1028
|
+
// Captured inside the withTracing scope so the outer post-response
|
|
1029
|
+
// path (response headers, metric labels, log attrs) can stamp the
|
|
1030
|
+
// trace ID without re-entering AsyncLocalStorage. The closure
|
|
1031
|
+
// captures whatever the framework span saw; if the bridge tracer
|
|
1032
|
+
// is a no-op, both stay empty strings and the header writes below
|
|
1033
|
+
// become no-ops.
|
|
1034
|
+
const identity = { requestId: "", traceId: "" };
|
|
1035
|
+
|
|
1005
1036
|
// Wrap the entire request in a RequestContext so that all code
|
|
1006
1037
|
// in the call stack (loaders, invoke handlers, vtexFetchWithCookies)
|
|
1007
1038
|
// can access the request and write response headers.
|
|
@@ -1011,16 +1042,43 @@ export function createDecoWorkerEntry(
|
|
|
1011
1042
|
// via getRuntimeEnv() in sdk/otelAdapters.ts.
|
|
1012
1043
|
setRuntimeEnv(env);
|
|
1013
1044
|
|
|
1045
|
+
// RequestContext.run already resolved request.id from the
|
|
1046
|
+
// inbound headers (precedence: x-request-id → cf-ray → UUID).
|
|
1047
|
+
// Lift it into the closure so the response-write path below has
|
|
1048
|
+
// access without going back through ALS.
|
|
1049
|
+
identity.requestId = RequestContext.requestId ?? "";
|
|
1050
|
+
|
|
1051
|
+
// W3C tracecontext propagation — parse the inbound `traceparent`
|
|
1052
|
+
// header so the OTLP trace exporter creates root spans under
|
|
1053
|
+
// the caller's trace ID. No-op when the header is absent or
|
|
1054
|
+
// malformed; the exporter falls back to a fresh trace ID.
|
|
1055
|
+
const incomingTraceparent = request.headers.get("traceparent");
|
|
1056
|
+
const remoteTrace = parseTraceparent(incomingTraceparent);
|
|
1057
|
+
if (remoteTrace) _setRequestTraceContext(remoteTrace);
|
|
1058
|
+
|
|
1014
1059
|
// Wrap inner handler in a single root span carrying our normalized
|
|
1015
|
-
// path/method attributes.
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1060
|
+
// path/method attributes. The framework span flows BOTH ways:
|
|
1061
|
+
// - via the OTLP direct-POST tracer (when DECO_OTEL_TRACES_ENDPOINT
|
|
1062
|
+
// is set) → ClickHouse `otel_traces`,
|
|
1063
|
+
// - via the @opentelemetry/api bridge → CF Workers Observability
|
|
1064
|
+
// when `observability.traces.enabled = true` in wrangler.jsonc.
|
|
1065
|
+
// See `configureTracerStack` in `otel.ts` for the composition.
|
|
1021
1066
|
return withTracing(
|
|
1022
1067
|
"deco.http.request",
|
|
1023
1068
|
async () => {
|
|
1069
|
+
// Stamp identity on the root span so every child span +
|
|
1070
|
+
// every log emitted under the same active span carries
|
|
1071
|
+
// them. Done inside the span scope so getActiveSpan()
|
|
1072
|
+
// returns the right span. Cheap no-op for any of these
|
|
1073
|
+
// when no tracer is configured.
|
|
1074
|
+
if (identity.requestId) {
|
|
1075
|
+
setSpanAttribute("request.id", identity.requestId);
|
|
1076
|
+
}
|
|
1077
|
+
const spanCtx = getActiveSpan()?.spanContext?.();
|
|
1078
|
+
if (spanCtx?.traceId) {
|
|
1079
|
+
identity.traceId = spanCtx.traceId;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1024
1082
|
// Run app middleware (injects app state into RequestContext.bag,
|
|
1025
1083
|
// runs registered middleware like VTEX cookie forwarding).
|
|
1026
1084
|
const appMw = getAppMiddleware();
|
|
@@ -1040,19 +1098,82 @@ export function createDecoWorkerEntry(
|
|
|
1040
1098
|
// invoke handlers, etc.) may independently append the same cookie.
|
|
1041
1099
|
deduplicateSetCookies(response);
|
|
1042
1100
|
|
|
1043
|
-
|
|
1101
|
+
let finalResponse = applySecurityHeaders(response);
|
|
1102
|
+
|
|
1103
|
+
// Echo request.id + trace.id back to the client / tail worker.
|
|
1104
|
+
// The CF tail worker reads these headers off the response to
|
|
1105
|
+
// stamp `request.id` / `trace.id` on tail rows — they're the
|
|
1106
|
+
// join key against direct-POST logs/metrics (which carry the
|
|
1107
|
+
// same values via the logger + span attributes).
|
|
1108
|
+
//
|
|
1109
|
+
// Headers are written defensively: if a downstream component
|
|
1110
|
+
// already set them (e.g. a proxy upstream), the existing value
|
|
1111
|
+
// wins. That's intentional — a load-balancer-supplied request.id
|
|
1112
|
+
// is more useful for cross-system correlation than ours.
|
|
1113
|
+
if (identity.requestId || identity.traceId) {
|
|
1114
|
+
// applySecurityHeaders may return either the original Response
|
|
1115
|
+
// (HTML path, fresh Headers) or the same Response (non-HTML
|
|
1116
|
+
// path). Either way Response.headers is mutable on Workers, so
|
|
1117
|
+
// we can set on it directly.
|
|
1118
|
+
if (identity.requestId && !finalResponse.headers.has("x-request-id")) {
|
|
1119
|
+
try {
|
|
1120
|
+
finalResponse.headers.set("x-request-id", identity.requestId);
|
|
1121
|
+
} catch {
|
|
1122
|
+
// Some intermediaries seal response headers (e.g. cached
|
|
1123
|
+
// responses replayed from the Cache API). Fall back to
|
|
1124
|
+
// building a new Response.
|
|
1125
|
+
const headers = new Headers(finalResponse.headers);
|
|
1126
|
+
if (identity.requestId) headers.set("x-request-id", identity.requestId);
|
|
1127
|
+
if (identity.traceId) headers.set("x-trace-id", identity.traceId);
|
|
1128
|
+
finalResponse = new Response(finalResponse.body, {
|
|
1129
|
+
status: finalResponse.status,
|
|
1130
|
+
statusText: finalResponse.statusText,
|
|
1131
|
+
headers,
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (identity.traceId && !finalResponse.headers.has("x-trace-id")) {
|
|
1136
|
+
try {
|
|
1137
|
+
finalResponse.headers.set("x-trace-id", identity.traceId);
|
|
1138
|
+
} catch {
|
|
1139
|
+
/* see above — sealed header case already handled */
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1044
1143
|
|
|
1045
1144
|
// Metrics + structured request log. Done after security headers so
|
|
1046
1145
|
// the recorded status reflects what the client actually receives.
|
|
1047
1146
|
// Both calls are no-ops when no meter / logger is configured.
|
|
1048
1147
|
const durationMs = performance.now() - startedAt;
|
|
1049
1148
|
try {
|
|
1050
|
-
|
|
1149
|
+
// Phase 2 / D-11 canonical labels — lift the cache decision +
|
|
1150
|
+
// profile + region off the response we just built so dashboards
|
|
1151
|
+
// can answer "cache hit rate per route" from `http_requests_total`
|
|
1152
|
+
// alone, no join to `cache_*_total` required.
|
|
1153
|
+
const xCacheRaw = finalResponse.headers.get("X-Cache");
|
|
1154
|
+
const cacheDecision = isCacheDecision(xCacheRaw) ? xCacheRaw : undefined;
|
|
1155
|
+
const colo = (request as unknown as { cf?: { colo?: string } }).cf?.colo;
|
|
1156
|
+
recordRequestMetric(method, reqUrl.pathname, finalResponse.status, durationMs, {
|
|
1157
|
+
...(cacheDecision ? { cache_decision: cacheDecision } : {}),
|
|
1158
|
+
...(cacheDecision ? { cache_layer: "edge" as const } : {}),
|
|
1159
|
+
...(typeof colo === "string" && colo.length > 0 ? { region: colo } : {}),
|
|
1160
|
+
...(identity.requestId || identity.traceId
|
|
1161
|
+
? {
|
|
1162
|
+
extra: {
|
|
1163
|
+
...(identity.requestId ? { "request.id": identity.requestId } : {}),
|
|
1164
|
+
...(identity.traceId ? { "trace.id": identity.traceId } : {}),
|
|
1165
|
+
},
|
|
1166
|
+
}
|
|
1167
|
+
: {}),
|
|
1168
|
+
});
|
|
1051
1169
|
} catch {
|
|
1052
1170
|
/* swallow — observability must never fail the request */
|
|
1053
1171
|
}
|
|
1054
1172
|
try {
|
|
1055
|
-
logRequest(request, finalResponse.status, durationMs
|
|
1173
|
+
logRequest(request, finalResponse.status, durationMs, {
|
|
1174
|
+
...(identity.requestId ? { "request.id": identity.requestId } : {}),
|
|
1175
|
+
...(identity.traceId ? { "trace.id": identity.traceId } : {}),
|
|
1176
|
+
});
|
|
1056
1177
|
} catch {
|
|
1057
1178
|
/* swallow */
|
|
1058
1179
|
}
|
|
@@ -1295,7 +1416,7 @@ export function createDecoWorkerEntry(
|
|
|
1295
1416
|
const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
|
|
1296
1417
|
|
|
1297
1418
|
if (ageSec < sfnEdge.fresh) {
|
|
1298
|
-
recordCacheMetric(true, sfnProfile, "HIT");
|
|
1419
|
+
recordCacheMetric(true, sfnProfile, "HIT", "edge");
|
|
1299
1420
|
const out = new Response(sfnCached.body, sfnCached);
|
|
1300
1421
|
const hdrs = cacheHeaders(sfnProfile);
|
|
1301
1422
|
for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
|
|
@@ -1305,7 +1426,7 @@ export function createDecoWorkerEntry(
|
|
|
1305
1426
|
}
|
|
1306
1427
|
|
|
1307
1428
|
if (ageSec < sfnEdge.fresh + sfnEdge.swr) {
|
|
1308
|
-
recordCacheMetric(true, sfnProfile, "STALE-HIT");
|
|
1429
|
+
recordCacheMetric(true, sfnProfile, "STALE-HIT", "edge");
|
|
1309
1430
|
// Stale-while-revalidate: serve stale, refresh in background
|
|
1310
1431
|
ctx.waitUntil(
|
|
1311
1432
|
(async () => {
|
|
@@ -1342,7 +1463,7 @@ export function createDecoWorkerEntry(
|
|
|
1342
1463
|
}
|
|
1343
1464
|
|
|
1344
1465
|
// Cache MISS — fetch origin with the body we already read
|
|
1345
|
-
recordCacheMetric(false, sfnProfile, "MISS");
|
|
1466
|
+
recordCacheMetric(false, sfnProfile, "MISS", "edge");
|
|
1346
1467
|
const origin = await serverEntry.fetch(originClone, env, ctx);
|
|
1347
1468
|
|
|
1348
1469
|
// Only cache responses explicitly marked as cacheable by the handler
|
|
@@ -1554,13 +1675,13 @@ export function createDecoWorkerEntry(
|
|
|
1554
1675
|
|
|
1555
1676
|
if (ageSec < edgeConfig.fresh) {
|
|
1556
1677
|
// FRESH HIT — serve immediately
|
|
1557
|
-
recordCacheMetric(true, profile, "HIT");
|
|
1678
|
+
recordCacheMetric(true, profile, "HIT", "edge");
|
|
1558
1679
|
return dressResponse(cached, "HIT");
|
|
1559
1680
|
}
|
|
1560
1681
|
|
|
1561
1682
|
if (ageSec < edgeConfig.fresh + edgeConfig.swr) {
|
|
1562
1683
|
// STALE-HIT within SWR window — serve stale, revalidate in background
|
|
1563
|
-
recordCacheMetric(true, profile, "STALE-HIT");
|
|
1684
|
+
recordCacheMetric(true, profile, "STALE-HIT", "edge");
|
|
1564
1685
|
revalidateInBackground();
|
|
1565
1686
|
return dressResponse(cached, "STALE-HIT", { "X-Cache-Age": String(Math.round(ageSec)) });
|
|
1566
1687
|
}
|
|
@@ -1570,7 +1691,7 @@ export function createDecoWorkerEntry(
|
|
|
1570
1691
|
}
|
|
1571
1692
|
|
|
1572
1693
|
// Cache MISS or past SWR window — fetch from origin
|
|
1573
|
-
recordCacheMetric(false, profile, "MISS");
|
|
1694
|
+
recordCacheMetric(false, profile, "MISS", "edge");
|
|
1574
1695
|
let origin: Response;
|
|
1575
1696
|
try {
|
|
1576
1697
|
origin = await serverEntry.fetch(request, env, ctx);
|
|
@@ -1584,7 +1705,7 @@ export function createDecoWorkerEntry(
|
|
|
1584
1705
|
console.warn(
|
|
1585
1706
|
`[edge-cache] Origin threw, serving stale (age=${Math.round(ageSec)}s, sie=${edgeConfig.sie}s)`,
|
|
1586
1707
|
);
|
|
1587
|
-
recordCacheMetric(true, profile, "STALE-ERROR");
|
|
1708
|
+
recordCacheMetric(true, profile, "STALE-ERROR", "edge");
|
|
1588
1709
|
return dressResponse(cached, "STALE-ERROR", {
|
|
1589
1710
|
"X-Cache-Age": String(Math.round(ageSec)),
|
|
1590
1711
|
});
|
|
@@ -1604,7 +1725,7 @@ export function createDecoWorkerEntry(
|
|
|
1604
1725
|
console.warn(
|
|
1605
1726
|
`[edge-cache] Origin ${origin.status}, serving stale (age=${Math.round(ageSec)}s)`,
|
|
1606
1727
|
);
|
|
1607
|
-
recordCacheMetric(true, profile, "STALE-ERROR");
|
|
1728
|
+
recordCacheMetric(true, profile, "STALE-ERROR", "edge");
|
|
1608
1729
|
return dressResponse(cached, "STALE-ERROR", {
|
|
1609
1730
|
"X-Cache-Age": String(Math.round(ageSec)),
|
|
1610
1731
|
"X-Cache-Origin-Status": String(origin.status),
|