@decocms/start 2.28.2 → 2.30.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.
Files changed (49) hide show
  1. package/.agents/skills/deco-to-tanstack-migration/SKILL.md +1 -1
  2. package/.agents/skills/deco-to-tanstack-migration/references/vtex-commerce.md +5 -1
  3. package/.agents/skills/deco-to-tanstack-migration/references/worker-cloudflare.md +107 -10
  4. package/.agents/skills/deco-to-tanstack-migration/templates/package-json.md +5 -1
  5. package/.cursor/rules/migration-tooling-policy.mdc +22 -2
  6. package/.github/workflows/deploy.yml +115 -0
  7. package/.github/workflows/preview.yml +143 -0
  8. package/.github/workflows/regen-blocks.yml +56 -0
  9. package/.github/workflows/release.yml +26 -0
  10. package/.github/workflows/sync-secrets.yml +173 -0
  11. package/CODEOWNERS +16 -0
  12. package/MIGRATION_TOOLING_PLAN.md +16 -4
  13. package/README.md +178 -79
  14. package/deploy/README.md +85 -0
  15. package/deploy/sites/als-tanstack.jsonc +7 -0
  16. package/deploy/sites/americanas-tanstack.jsonc +4 -0
  17. package/deploy/sites/baggagio-tanstack.jsonc +4 -0
  18. package/deploy/sites/casaevideo-storefront.jsonc +11 -0
  19. package/deploy/sites/lebiscuit-tanstack.jsonc +19 -0
  20. package/deploy/sites/miess-01-tanstack.jsonc +8 -0
  21. package/deploy/wrangler-template.jsonc +28 -0
  22. package/package.json +18 -15
  23. package/scripts/deploy/build-wrangler-config.mjs +49 -0
  24. package/scripts/deploy/jsonc.mjs +76 -0
  25. package/scripts/deploy/resolve-site.mjs +58 -0
  26. package/scripts/deploy/site-registry.mjs +142 -0
  27. package/scripts/deploy/wrangler-wrapper.mjs +126 -0
  28. package/scripts/migrate/phase-scaffold.ts +13 -3
  29. package/scripts/migrate/phase-verify.ts +6 -1
  30. package/scripts/migrate/templates/github-workflows.ts +98 -0
  31. package/scripts/migrate/templates/package-json.ts +9 -2
  32. package/src/cms/resolve.ts +81 -63
  33. package/src/cms/sectionLoaders.ts +11 -0
  34. package/src/index.ts +3 -0
  35. package/src/sdk/cachedLoader.ts +36 -13
  36. package/src/sdk/composite.test.ts +121 -0
  37. package/src/sdk/composite.ts +114 -0
  38. package/src/sdk/instrumentedFetch.ts +56 -0
  39. package/src/sdk/logger.test.ts +135 -0
  40. package/src/sdk/logger.ts +166 -0
  41. package/src/sdk/observability.ts +75 -0
  42. package/src/sdk/otel.test.ts +59 -0
  43. package/src/sdk/otel.ts +270 -29
  44. package/src/sdk/otelAdapters.test.ts +135 -0
  45. package/src/sdk/otelAdapters.ts +401 -0
  46. package/src/sdk/sampler.test.ts +127 -0
  47. package/src/sdk/sampler.ts +183 -0
  48. package/src/sdk/workerEntry.ts +541 -476
  49. package/scripts/migrate/templates/wrangler.ts +0 -30
package/src/sdk/otel.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  /**
2
- * OpenTelemetry integration for Cloudflare Workers via @microlabs/otel-cf-workers.
2
+ * Single observability entry point for `@decocms/start` on Cloudflare Workers.
3
3
  *
4
- * Opt-in module that wraps a Worker handler with auto-instrumentation and
5
- * wires traces into @decocms/start's pluggable TracerAdapter.
4
+ * `instrumentWorker(handler, options)` wraps a Worker handler with:
5
+ * - structured JSON logger (stdout → Cloudflare Logs / Logpush) — always
6
+ * - OTLP/HTTP logs exporter (HyperDX) — when `OTEL_EXPORTER_OTLP_ENDPOINT` is set
7
+ * - OTLP/HTTP metrics exporter (HyperDX) — same condition
8
+ * - Workers Analytics Engine metrics — when `env.DECO_METRICS` binding exists
9
+ * - OTel traces via `@microlabs/otel-cf-workers` — when OTLP endpoint is set,
10
+ * or always-on for the framework's internal `withTracing()` calls.
11
+ * - URL-based head sampler from `OTEL_SAMPLING_CONFIG`
12
+ * - OTel `Resource` with service.* / cloud.* / deployment.environment /
13
+ * deco.runtime.version attributes
6
14
  *
7
- * Requires peer dependencies:
8
- * - `@microlabs/otel-cf-workers`
9
- * - `@opentelemetry/api`
15
+ * Removing HyperDX = unsetting `OTEL_EXPORTER_OTLP_ENDPOINT`. The console-JSON
16
+ * logger and AE metrics keep flowing — this is the "no vendor lock-in" guarantee.
10
17
  *
11
18
  * @example
12
19
  * ```ts
@@ -18,30 +25,64 @@
18
25
  * export default instrumentWorker(handler, { serviceName: "my-store" });
19
26
  * ```
20
27
  *
21
- * Environment variables (read from `env` at request time):
22
- * - `OTEL_EXPORTER_OTLP_ENDPOINT` — OTLP endpoint (e.g. `https://in-otel.hyperdx.io`)
23
- * - `OTEL_EXPORTER_OTLP_HEADERS` comma-separated `key=value` auth headers
28
+ * Wrangler bindings to add when enabling OTLP + AE:
29
+ * ```jsonc
30
+ * "version_metadata": { "binding": "CF_VERSION_METADATA" },
31
+ * "analytics_engine_datasets": [{ "binding": "DECO_METRICS", "dataset": "deco_metrics_my_site" }]
32
+ * ```
33
+ *
34
+ * Required Worker secrets when OTLP is enabled:
35
+ * wrangler secret put OTEL_EXPORTER_OTLP_ENDPOINT # https://in-otel.hyperdx.io
36
+ * wrangler secret put OTEL_EXPORTER_OTLP_HEADERS # authorization=<token>
24
37
  */
25
38
 
26
39
  import { instrument, type ResolveConfigFn } from "@microlabs/otel-cf-workers";
27
40
  import { trace } from "@opentelemetry/api";
28
- import { configureTracer } from "../middleware/observability";
41
+ import { type Resource, resourceFromAttributes } from "@opentelemetry/resources";
42
+
43
+ import { configureMeter, configureTracer } from "../middleware/observability";
44
+ import { createCompositeLogger, createCompositeMeter } from "./composite";
45
+ import { configureLogger, defaultLoggerAdapter, logger } from "./logger";
46
+ import {
47
+ createAnalyticsEngineMeterAdapter,
48
+ createOtelLoggerAdapter,
49
+ createOtelMeterAdapter,
50
+ setRuntimeEnv,
51
+ } from "./otelAdapters";
52
+ import { RequestContext } from "./requestContext";
53
+ import { createUrlBasedHeadSampler, decodeSamplingConfig } from "./sampler";
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Types
57
+ // ---------------------------------------------------------------------------
29
58
 
30
59
  export interface OtelOptions {
31
- serviceName: string;
32
- /** OTLP endpoint. Defaults to env.OTEL_EXPORTER_OTLP_ENDPOINT. */
60
+ /** Logical service name. Falls back to `env.DECO_SITE_NAME`, then "deco-site". */
61
+ serviceName?: string;
62
+ /** Override OTLP endpoint. Defaults to `env.OTEL_EXPORTER_OTLP_ENDPOINT`. */
33
63
  endpoint?: string;
34
- /** OTLP auth headers. Defaults to env.OTEL_EXPORTER_OTLP_HEADERS parsed. */
64
+ /** Override OTLP auth headers. Defaults to parsed `env.OTEL_EXPORTER_OTLP_HEADERS`. */
35
65
  headers?: Record<string, string>;
66
+ /** Env var name holding the AE binding. Defaults to `"DECO_METRICS"`. */
67
+ analyticsEngineBindingName?: string;
68
+ /** Set to `false` to disable AE even when the binding is present. */
69
+ analyticsEngineEnabled?: boolean;
70
+ /** Push interval for OTLP metrics, in ms. Defaults to env.OTEL_EXPORT_INTERVAL or 60_000. */
71
+ metricsExportIntervalMillis?: number;
72
+ /**
73
+ * Version of `@decocms/start` to advertise as `deco.runtime.version`.
74
+ * Falls back to a build-time constant; override only for tests.
75
+ */
76
+ decoRuntimeVersion?: string;
77
+ /** Optional `@decocms/apps` version, advertised as `deco.apps.version`. */
78
+ decoAppsVersion?: string;
36
79
  }
37
80
 
38
- /** Minimal Cloudflare Worker execution context. */
39
81
  interface WorkerExecutionContext {
40
82
  waitUntil(promise: Promise<unknown>): void;
41
83
  passThroughOnException(): void;
42
84
  }
43
85
 
44
- /** Handler shape returned by createDecoWorkerEntry. */
45
86
  interface WorkerHandler {
46
87
  fetch(
47
88
  request: Request,
@@ -50,19 +91,40 @@ interface WorkerHandler {
50
91
  ): Promise<Response>;
51
92
  }
52
93
 
94
+ // ---------------------------------------------------------------------------
95
+ // Boot state — guard against double-init across worker reloads
96
+ // ---------------------------------------------------------------------------
97
+
98
+ let booted = false;
99
+
100
+ interface BootState {
101
+ serviceName: string;
102
+ otlpEndpoint: string | null;
103
+ otlpHeaders: Record<string, string>;
104
+ resource: Resource;
105
+ }
106
+
107
+ let bootState: BootState | null = null;
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // instrumentWorker
111
+ // ---------------------------------------------------------------------------
112
+
53
113
  /**
54
- * Wraps a Cloudflare Worker handler with OpenTelemetry auto-instrumentation
55
- * (fetch, KV, D1, waitUntil) and connects to @decocms/start's TracerAdapter
56
- * so that `withTracing()` / `createInstrumentedFetch()` emit real OTel spans.
114
+ * Wraps a Cloudflare Worker handler with the full @decocms/start
115
+ * observability stack. Idempotent calling twice on the same handler
116
+ * is a no-op (returns the already-instrumented handler).
57
117
  */
58
118
  export function instrumentWorker(
59
119
  handler: WorkerHandler,
60
- options: OtelOptions | ((env: Record<string, unknown>) => OtelOptions),
61
- ) {
62
- // Bridge @decocms/start TracerAdapter @opentelemetry/api
120
+ options: OtelOptions | ((env: Record<string, unknown>) => OtelOptions) = {},
121
+ ): WorkerHandler {
122
+ // Bridge our pluggable TracerAdapter onto @opentelemetry/api so
123
+ // framework-internal `withTracing()` calls produce real OTel spans
124
+ // for whatever exporter is configured (OTLP, console, etc.).
63
125
  configureTracer({
64
126
  startSpan: (name, attrs) => {
65
- const span = trace.getTracer("deco").startSpan(name, { attributes: attrs });
127
+ const span = trace.getTracer("@decocms/start").startSpan(name, { attributes: attrs });
66
128
  return {
67
129
  end: () => span.end(),
68
130
  setError: (error) => {
@@ -75,21 +137,178 @@ export function instrumentWorker(
75
137
 
76
138
  const resolveConfig: ResolveConfigFn = (env, _trigger) => {
77
139
  const opts = typeof options === "function" ? options(env as Record<string, unknown>) : options;
78
- const endpoint = opts.endpoint || (env.OTEL_EXPORTER_OTLP_ENDPOINT as string);
79
- const headers = opts.headers || parseHeaders(env.OTEL_EXPORTER_OTLP_HEADERS as string | undefined);
140
+ bootObservability(opts, env as Record<string, unknown>);
141
+
142
+ const state = bootState!;
143
+
144
+ // Sampling — base64 JSON via OTEL_SAMPLING_CONFIG, see sdk/sampler.ts
145
+ const samplingConfig = decodeSamplingConfig(env.OTEL_SAMPLING_CONFIG as string | undefined);
146
+ const headSampler = createUrlBasedHeadSampler(samplingConfig);
147
+
148
+ // microlabs requires an exporter even when we only want internal
149
+ // tracing. When OTLP isn't configured, we still set up a no-op
150
+ // collector — the URL we'd never reach so spans simply drop.
151
+ const exporterUrl = state.otlpEndpoint ?? "http://127.0.0.1:0/v1/traces";
80
152
 
81
153
  return {
82
- exporter: { url: endpoint, headers },
83
- service: { name: opts.serviceName },
154
+ exporter: {
155
+ url: joinPath(exporterUrl, "/v1/traces"),
156
+ headers: state.otlpHeaders,
157
+ },
158
+ service: {
159
+ name: state.serviceName,
160
+ version: (env.CF_VERSION_METADATA as { id?: string } | undefined)?.id,
161
+ },
162
+ sampling: { headSampler },
163
+ // microlabs auto-instruments globalThis.fetch + KV + waitUntil.
164
+ instrumentation: {
165
+ instrumentGlobalFetch: true,
166
+ instrumentGlobalCache: true,
167
+ },
84
168
  };
85
169
  };
86
170
 
87
- // Cast through `any` — @microlabs/otel-cf-workers expects Cloudflare's
88
- // ExportedHandler type, but we avoid depending on @cloudflare/workers-types.
171
+ const innerHandler: WorkerHandler = {
172
+ async fetch(request, env, ctx) {
173
+ // Stash env so request-scoped adapters (AE) can resolve their bindings.
174
+ // Done inside RequestContext.run wrapping in workerEntry.ts as well, but
175
+ // for instrumentWorker we re-stash in case this handler is wrapped over
176
+ // the top of a Worker that doesn't go through createDecoWorkerEntry.
177
+ const wrap = async () => {
178
+ setRuntimeEnv(env);
179
+ return handler.fetch(request, env, ctx);
180
+ };
181
+
182
+ // RequestContext may already be active (createDecoWorkerEntry sets it
183
+ // up). If so, run inline; otherwise wrap. Cheap to detect via current.
184
+ if (RequestContext.current) {
185
+ return wrap();
186
+ }
187
+ return RequestContext.run(request, wrap);
188
+ },
189
+ };
190
+
89
191
  // deno-lint-ignore no-explicit-any
90
- return instrument(handler as any, resolveConfig);
192
+ return instrument(innerHandler as any, resolveConfig) as unknown as WorkerHandler;
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Boot — wires the loggers/meters once (per worker isolate)
197
+ // ---------------------------------------------------------------------------
198
+
199
+ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): void {
200
+ if (booted && bootState) return;
201
+
202
+ const serviceName = opts.serviceName ?? (env.DECO_SITE_NAME as string | undefined) ?? "deco-site";
203
+
204
+ const otlpEndpoint =
205
+ opts.endpoint ?? (env.OTEL_EXPORTER_OTLP_ENDPOINT as string | undefined) ?? null;
206
+ const otlpHeaders =
207
+ opts.headers ?? parseHeaders(env.OTEL_EXPORTER_OTLP_HEADERS as string | undefined);
208
+
209
+ const resource = buildResource({
210
+ serviceName,
211
+ serviceVersion: (env.CF_VERSION_METADATA as { id?: string } | undefined)?.id,
212
+ serviceInstanceId: cryptoRandomId(),
213
+ deploymentEnvironment: (env.DECO_ENV_NAME as string | undefined) ?? "production",
214
+ decoRuntimeVersion: opts.decoRuntimeVersion ?? DECO_RUNTIME_VERSION,
215
+ decoAppsVersion: opts.decoAppsVersion,
216
+ });
217
+
218
+ // ---- Logger ----------------------------------------------------------
219
+ const otelLogger =
220
+ otlpEndpoint != null
221
+ ? createOtelLoggerAdapter({
222
+ endpoint: otlpEndpoint,
223
+ headers: otlpHeaders,
224
+ resource,
225
+ name: serviceName,
226
+ })
227
+ : null;
228
+
229
+ configureLogger(createCompositeLogger([defaultLoggerAdapter, otelLogger]));
230
+
231
+ // ---- Meter -----------------------------------------------------------
232
+ const otelMeter =
233
+ otlpEndpoint != null
234
+ ? createOtelMeterAdapter({
235
+ endpoint: otlpEndpoint,
236
+ headers: otlpHeaders,
237
+ resource,
238
+ exportIntervalMillis:
239
+ opts.metricsExportIntervalMillis ?? numericEnv(env.OTEL_EXPORT_INTERVAL, 60_000),
240
+ name: serviceName,
241
+ })
242
+ : null;
243
+
244
+ const aeBindingName = opts.analyticsEngineBindingName ?? "DECO_METRICS";
245
+ const aeEnabled = opts.analyticsEngineEnabled !== false && Boolean(env[aeBindingName]);
246
+ const aeMeter = aeEnabled
247
+ ? createAnalyticsEngineMeterAdapter({ bindingName: aeBindingName })
248
+ : null;
249
+
250
+ configureMeter(createCompositeMeter([aeMeter, otelMeter]));
251
+
252
+ bootState = {
253
+ serviceName,
254
+ otlpEndpoint,
255
+ otlpHeaders,
256
+ resource,
257
+ };
258
+ booted = true;
259
+
260
+ // Single boot-time breadcrumb so operators can confirm the wiring at a
261
+ // glance from CF Logs without enabling debug.
262
+ logger.info("observability booted", {
263
+ service: serviceName,
264
+ otlp: Boolean(otlpEndpoint),
265
+ analyticsEngine: aeEnabled,
266
+ sampling: Boolean(env.OTEL_SAMPLING_CONFIG),
267
+ runtimeVersion: opts.decoRuntimeVersion ?? DECO_RUNTIME_VERSION,
268
+ });
91
269
  }
92
270
 
271
+ // ---------------------------------------------------------------------------
272
+ // Resource attributes
273
+ // ---------------------------------------------------------------------------
274
+
275
+ interface ResourceInput {
276
+ serviceName: string;
277
+ serviceVersion?: string;
278
+ serviceInstanceId: string;
279
+ deploymentEnvironment: string;
280
+ decoRuntimeVersion: string;
281
+ decoAppsVersion?: string;
282
+ }
283
+
284
+ function buildResource(input: ResourceInput): Resource {
285
+ const attrs: Record<string, string> = {
286
+ "service.name": input.serviceName,
287
+ "service.version": input.serviceVersion ?? "unknown",
288
+ "service.instance.id": input.serviceInstanceId,
289
+ "cloud.provider": "cloudflare",
290
+ "deployment.environment": input.deploymentEnvironment,
291
+ "deco.runtime.version": input.decoRuntimeVersion,
292
+ };
293
+ if (input.decoAppsVersion) attrs["deco.apps.version"] = input.decoAppsVersion;
294
+ return resourceFromAttributes(attrs);
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Helpers
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /**
302
+ * Build-time @decocms/start version. Hand-bumped at release; we deliberately
303
+ * avoid `import("../../package.json")` to keep the module side-effect-free
304
+ * and JSON-import-quirk-free across the various build pipelines that
305
+ * consume @decocms/start.
306
+ *
307
+ * Drift is acceptable — this attribute is for operator triage, not for
308
+ * billing / SLOs.
309
+ */
310
+ const DECO_RUNTIME_VERSION = "2.28.2";
311
+
93
312
  function parseHeaders(str?: string): Record<string, string> {
94
313
  if (!str) return {};
95
314
  return Object.fromEntries(
@@ -102,3 +321,25 @@ function parseHeaders(str?: string): Record<string, string> {
102
321
  .filter(([k]) => k.length > 0),
103
322
  );
104
323
  }
324
+
325
+ function numericEnv(value: unknown, fallback: number): number {
326
+ const n = Number(value);
327
+ return Number.isFinite(n) && n > 0 ? n : fallback;
328
+ }
329
+
330
+ function joinPath(base: string, path: string): string {
331
+ if (!base) return path;
332
+ if (base.endsWith("/")) base = base.slice(0, -1);
333
+ if (!path.startsWith("/")) path = "/" + path;
334
+ if (base.toLowerCase().endsWith(path.toLowerCase())) return base;
335
+ return base + path;
336
+ }
337
+
338
+ function cryptoRandomId(): string {
339
+ // crypto.randomUUID is universally available in CF Workers + Node 19+.
340
+ try {
341
+ return crypto.randomUUID();
342
+ } catch {
343
+ return `inst-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
344
+ }
345
+ }
@@ -0,0 +1,135 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ createAnalyticsEngineMeterAdapter,
4
+ createOtelLoggerAdapter,
5
+ createOtelMeterAdapter,
6
+ setRuntimeEnv,
7
+ } from "./otelAdapters";
8
+ import { RequestContext } from "./requestContext";
9
+
10
+ describe("createOtelLoggerAdapter / createOtelMeterAdapter", () => {
11
+ afterEach(() => vi.restoreAllMocks());
12
+
13
+ it("returns null when no endpoint is provided (no-op safe)", () => {
14
+ expect(createOtelLoggerAdapter(null)).toBeNull();
15
+ expect(createOtelLoggerAdapter({ endpoint: "" })).toBeNull();
16
+ expect(createOtelMeterAdapter(null)).toBeNull();
17
+ });
18
+
19
+ it("constructs without throwing when an endpoint is provided", () => {
20
+ // We don't want this test to actually open a network connection.
21
+ // The exporters lazy-initialize their HTTP client and only POST on
22
+ // batch flush, so simply constructing must not throw.
23
+ const log = createOtelLoggerAdapter({
24
+ endpoint: "https://otel.example.invalid",
25
+ headers: { authorization: "test" },
26
+ name: "test-svc",
27
+ });
28
+ expect(log).not.toBeNull();
29
+ expect(typeof log!.log).toBe("function");
30
+
31
+ const meter = createOtelMeterAdapter({
32
+ endpoint: "https://otel.example.invalid",
33
+ headers: { authorization: "test" },
34
+ exportIntervalMillis: 60_000,
35
+ name: "test-svc",
36
+ });
37
+ expect(meter).not.toBeNull();
38
+ expect(typeof meter!.counterInc).toBe("function");
39
+ expect(typeof meter!.histogramRecord).toBe("function");
40
+ });
41
+
42
+ it("logs without throwing for a variety of attribute shapes", () => {
43
+ const log = createOtelLoggerAdapter({ endpoint: "https://otel.example.invalid" });
44
+ expect(log).not.toBeNull();
45
+ expect(() =>
46
+ log!.log("info", "ok", {
47
+ s: "x",
48
+ n: 1,
49
+ b: true,
50
+ arr: [1, "two", true],
51
+ nested: { a: 1, b: { c: "d" } },
52
+ nullV: null,
53
+ undef: undefined,
54
+ fn: () => {},
55
+ sym: Symbol("s") as unknown as string,
56
+ }),
57
+ ).not.toThrow();
58
+ });
59
+ });
60
+
61
+ describe("createAnalyticsEngineMeterAdapter", () => {
62
+ afterEach(() => vi.restoreAllMocks());
63
+
64
+ it("no-ops when binding is missing (does not throw)", () => {
65
+ const meter = createAnalyticsEngineMeterAdapter({ bindingName: "MISSING" });
66
+ expect(() => meter.counterInc("foo", 1)).not.toThrow();
67
+ expect(() => meter.gaugeSet?.("bar", 5)).not.toThrow();
68
+ expect(() => meter.histogramRecord?.("baz", 100)).not.toThrow();
69
+ });
70
+
71
+ it("writes one data point per call to the resolved binding", () => {
72
+ const writeDataPoint = vi.fn();
73
+ const binding = { writeDataPoint };
74
+
75
+ const meter = createAnalyticsEngineMeterAdapter({ binding });
76
+
77
+ meter.counterInc("http_requests_total", 1, { method: "GET", path: "/", status: 200 });
78
+ meter.histogramRecord?.("http_request_duration_ms", 150, {
79
+ method: "GET",
80
+ path: "/",
81
+ status: 200,
82
+ });
83
+ meter.gaugeSet?.("custom_gauge", 7, { region: "gru" });
84
+
85
+ expect(writeDataPoint).toHaveBeenCalledTimes(3);
86
+
87
+ // For HTTP request metrics, indexes[0] must be the path.
88
+ expect(writeDataPoint.mock.calls[0]?.[0].indexes).toEqual(["/"]);
89
+ expect(writeDataPoint.mock.calls[0]?.[0].doubles).toEqual([1]);
90
+ expect(writeDataPoint.mock.calls[0]?.[0].blobs?.[0]).toBe("http_requests_total");
91
+
92
+ expect(writeDataPoint.mock.calls[1]?.[0].indexes).toEqual(["/"]);
93
+ expect(writeDataPoint.mock.calls[1]?.[0].doubles).toEqual([150]);
94
+
95
+ // Non-HTTP metrics fall back to the metric name as the index.
96
+ expect(writeDataPoint.mock.calls[2]?.[0].indexes).toEqual(["custom_gauge"]);
97
+ expect(writeDataPoint.mock.calls[2]?.[0].doubles).toEqual([7]);
98
+ });
99
+
100
+ it("resolves binding via RequestContext when not provided directly", () => {
101
+ const writeDataPoint = vi.fn();
102
+ const env = { DECO_METRICS: { writeDataPoint } };
103
+
104
+ RequestContext.run(new Request("https://x.example/"), () => {
105
+ setRuntimeEnv(env);
106
+ const meter = createAnalyticsEngineMeterAdapter();
107
+ // Use a known HTTP metric name so path is promoted to index[0].
108
+ meter.counterInc("http_requests_total", 1, { path: "/x" });
109
+ expect(writeDataPoint).toHaveBeenCalledOnce();
110
+ expect(writeDataPoint.mock.calls[0]?.[0].indexes).toEqual(["/x"]);
111
+ });
112
+ });
113
+
114
+ it("never throws when binding throws", () => {
115
+ const binding = {
116
+ writeDataPoint: () => {
117
+ throw new Error("AE down");
118
+ },
119
+ };
120
+ const meter = createAnalyticsEngineMeterAdapter({ binding });
121
+ expect(() => meter.counterInc("x", 1)).not.toThrow();
122
+ });
123
+
124
+ it("orders blob columns deterministically by sorted label keys", () => {
125
+ const writeDataPoint = vi.fn();
126
+ const binding = { writeDataPoint };
127
+ const meter = createAnalyticsEngineMeterAdapter({ binding });
128
+
129
+ meter.counterInc("metric", 1, { z: "last", a: "first", m: "mid" });
130
+
131
+ const blobs = writeDataPoint.mock.calls[0]?.[0].blobs as string[];
132
+ // blobs[0] is metric name; remaining must follow sorted label-key order: a, m, z
133
+ expect(blobs).toEqual(["metric", "first", "mid", "last"]);
134
+ });
135
+ });