@decocms/start 6.0.1 → 6.1.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.
@@ -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. With Cloudflare-managed trace export
1016
- // (`observability.traces.destinations` in wrangler.jsonc), this
1017
- // span and any `withTracing` spans nested below it — flow to
1018
- // HyperDX via CF's platform-managed OTLP push, since the bridge
1019
- // in `instrumentWorker` configures the `@opentelemetry/api`
1020
- // global tracer for us.
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
- const finalResponse = applySecurityHeaders(response);
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
- recordRequestMetric(method, reqUrl.pathname, finalResponse.status, durationMs);
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),