@decocms/blocks 7.30.0 → 7.31.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.30.0",
3
+ "version": "7.31.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -75,27 +75,45 @@ describe("previewApiOriginForHost", () => {
75
75
  expect(previewApiOriginForHost("abc.local.studio.decocms.com", {})).toBe(
76
76
  "https://abc.local.studio.decocms.com",
77
77
  );
78
- expect(previewApiOriginForHost("abc.localhost:60534", {})).toBe("http://abc.localhost:60534");
78
+ expect(previewApiOriginForHost("abc.localhost:60534", {})).toBe(
79
+ "http://abc.localhost:60534",
80
+ );
81
+ // -stg is its own suffix, not a substring match of .preview-studio.decocms.com —
82
+ // both must be listed explicitly (regressed once: staging draft pointers
83
+ // silently fell back to published content with no visible error).
84
+ expect(
85
+ previewApiOriginForHost("abc.preview-studio-stg.decocms.com", {}),
86
+ ).toBe("https://abc.preview-studio-stg.decocms.com");
79
87
  });
80
88
 
81
89
  it("rejects hosts outside the domains — the token proposes, config disposes", () => {
82
90
  expect(previewApiOriginForHost("evil.example", {})).toBeNull();
83
91
  // Dot-prefixed suffixes guarantee a label boundary: a lookalike domain
84
92
  // that merely ends with the same characters cannot pass.
85
- expect(previewApiOriginForHost("evil-preview-studio.decocms.com", {})).toBeNull();
93
+ expect(
94
+ previewApiOriginForHost("evil-preview-studio.decocms.com", {}),
95
+ ).toBeNull();
86
96
  // The domain itself (no label in front) is not a draft host.
87
- expect(previewApiOriginForHost("preview-studio.decocms.com", {})).toBeNull();
97
+ expect(
98
+ previewApiOriginForHost("preview-studio.decocms.com", {}),
99
+ ).toBeNull();
88
100
  });
89
101
 
90
102
  it("allows an explicit port only under localhost-ish domains", () => {
91
103
  // A public-domain token must not steer the fetch at odd ports.
92
- expect(previewApiOriginForHost("abc.preview-studio.decocms.com:8500", {})).toBeNull();
104
+ expect(
105
+ previewApiOriginForHost("abc.preview-studio.decocms.com:8500", {}),
106
+ ).toBeNull();
93
107
  });
94
108
 
95
109
  it("honours a configured override instead of the defaults", () => {
96
110
  const env = { DECO_PREVIEW_API_DOMAINS: ".staging.example" };
97
- expect(previewApiOriginForHost("abc.staging.example", env)).toBe("https://abc.staging.example");
98
- expect(previewApiOriginForHost("abc.preview-studio.decocms.com", env)).toBeNull();
111
+ expect(previewApiOriginForHost("abc.staging.example", env)).toBe(
112
+ "https://abc.staging.example",
113
+ );
114
+ expect(
115
+ previewApiOriginForHost("abc.preview-studio.decocms.com", env),
116
+ ).toBeNull();
99
117
  });
100
118
  });
101
119
 
@@ -129,7 +147,9 @@ describe("resolveDraftDecofile", () => {
129
147
  });
130
148
 
131
149
  expect(blocks).toEqual({ "pages-home": { title: "draft" } });
132
- expect(calls).toEqual(["https://abc.preview-studio.decocms.com/_sandbox/decofile"]);
150
+ expect(calls).toEqual([
151
+ "https://abc.preview-studio.decocms.com/_sandbox/decofile",
152
+ ]);
133
153
  });
134
154
 
135
155
  it("is inert without a host allowlist — no fetch at all", async () => {
@@ -168,8 +188,16 @@ describe("resolveDraftDecofile", () => {
168
188
  }) as unknown as typeof fetch;
169
189
  const P = "abc.preview-studio.decocms.com";
170
190
 
171
- const a = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
172
- const b = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
191
+ const a = await resolveDraftDecofile({
192
+ pointer: `${P}@v1`,
193
+ env: ENV_ON,
194
+ fetchImpl,
195
+ });
196
+ const b = await resolveDraftDecofile({
197
+ pointer: `${P}@v1`,
198
+ env: ENV_ON,
199
+ fetchImpl,
200
+ });
173
201
  expect(fetches).toBe(1);
174
202
  expect(b).toBe(a);
175
203
 
@@ -186,7 +214,11 @@ describe("resolveDraftDecofile", () => {
186
214
  const P = "abc.preview-studio.decocms.com";
187
215
 
188
216
  for (const v of ["v1", "v2", "v3", "v4"]) {
189
- await resolveDraftDecofile({ pointer: `${P}@${v}`, env: ENV_ON, fetchImpl });
217
+ await resolveDraftDecofile({
218
+ pointer: `${P}@${v}`,
219
+ env: ENV_ON,
220
+ fetchImpl,
221
+ });
190
222
  }
191
223
  expect(fetches).toBe(4);
192
224
  await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
@@ -225,6 +257,7 @@ describe("DEFAULT_PREVIEW_API_DOMAINS", () => {
225
257
  it("ships the deco-operated origins, dot-prefixed", () => {
226
258
  expect(DEFAULT_PREVIEW_API_DOMAINS).toEqual([
227
259
  ".preview-studio.decocms.com",
260
+ ".preview-studio-stg.decocms.com",
228
261
  ".local.studio.decocms.com",
229
262
  ".localhost",
230
263
  ]);
@@ -261,10 +294,17 @@ describe("site-block preview hosts", () => {
261
294
 
262
295
  describe("draftPointerFromRequest", () => {
263
296
  it("reads the pointer from the __deco_draft cookie (in-preview navigation)", () => {
264
- const req = new Request("https://preview.example/deco/invoke/site/loaders/x.ts", {
265
- headers: { cookie: "a=1; __deco_draft=abc.preview-studio.decocms.com@v1; b=2" },
266
- });
267
- expect(draftPointerFromRequest(req)).toBe("abc.preview-studio.decocms.com@v1");
297
+ const req = new Request(
298
+ "https://preview.example/deco/invoke/site/loaders/x.ts",
299
+ {
300
+ headers: {
301
+ cookie: "a=1; __deco_draft=abc.preview-studio.decocms.com@v1; b=2",
302
+ },
303
+ },
304
+ );
305
+ expect(draftPointerFromRequest(req)).toBe(
306
+ "abc.preview-studio.decocms.com@v1",
307
+ );
268
308
  });
269
309
 
270
310
  it("lets ?__draft= win over the cookie", () => {
@@ -282,26 +322,35 @@ describe("draftPointerFromRequest", () => {
282
322
  });
283
323
 
284
324
  it("returns null with neither param nor cookie", () => {
285
- expect(draftPointerFromRequest(new Request("https://preview.example/p"))).toBeNull();
325
+ expect(
326
+ draftPointerFromRequest(new Request("https://preview.example/p")),
327
+ ).toBeNull();
286
328
  });
287
329
  });
288
330
 
289
331
  describe("resolveDraftForRequest", () => {
290
332
  const ENV = { DECO_ALLOWED_PREVIEW_HOSTS: "preview.example" };
291
333
  function invokeReq(host = "preview.example"): Request {
292
- return new Request("https://preview.example/deco/invoke/site/loaders/x.ts", {
293
- method: "POST",
294
- headers: {
295
- "x-forwarded-host": host,
296
- cookie: "__deco_draft=abc.preview-studio.decocms.com@v1",
334
+ return new Request(
335
+ "https://preview.example/deco/invoke/site/loaders/x.ts",
336
+ {
337
+ method: "POST",
338
+ headers: {
339
+ "x-forwarded-host": host,
340
+ cookie: "__deco_draft=abc.preview-studio.decocms.com@v1",
341
+ },
297
342
  },
298
- });
343
+ );
299
344
  }
300
345
 
301
346
  it("binds the draft when host is allowed and the pointer resolves", async () => {
302
347
  const fetchImpl = (async () =>
303
- jsonResponse({ "site/x": { value: "draft" } })) as unknown as typeof fetch;
304
- expect(await resolveDraftForRequest(invokeReq(), { env: ENV, fetchImpl })).toEqual({
348
+ jsonResponse({
349
+ "site/x": { value: "draft" },
350
+ })) as unknown as typeof fetch;
351
+ expect(
352
+ await resolveDraftForRequest(invokeReq(), { env: ENV, fetchImpl }),
353
+ ).toEqual({
305
354
  "site/x": { value: "draft" },
306
355
  });
307
356
  });
@@ -312,7 +361,9 @@ describe("resolveDraftForRequest", () => {
312
361
  called = true;
313
362
  return jsonResponse({});
314
363
  }) as unknown as typeof fetch;
315
- expect(await resolveDraftForRequest(invokeReq(), { env: {}, fetchImpl })).toBeNull();
364
+ expect(
365
+ await resolveDraftForRequest(invokeReq(), { env: {}, fetchImpl }),
366
+ ).toBeNull();
316
367
  expect(called).toBe(false);
317
368
  });
318
369
 
@@ -323,21 +374,29 @@ describe("resolveDraftForRequest", () => {
323
374
  return jsonResponse({});
324
375
  }) as unknown as typeof fetch;
325
376
  expect(
326
- await resolveDraftForRequest(invokeReq("prod.example"), { env: ENV, fetchImpl }),
377
+ await resolveDraftForRequest(invokeReq("prod.example"), {
378
+ env: ENV,
379
+ fetchImpl,
380
+ }),
327
381
  ).toBeNull();
328
382
  expect(called).toBe(false);
329
383
  });
330
384
 
331
385
  it("returns null when the request carries no draft pointer", async () => {
332
- const req = new Request("https://preview.example/deco/invoke/site/loaders/x.ts", {
333
- headers: { "x-forwarded-host": "preview.example" },
334
- });
386
+ const req = new Request(
387
+ "https://preview.example/deco/invoke/site/loaders/x.ts",
388
+ {
389
+ headers: { "x-forwarded-host": "preview.example" },
390
+ },
391
+ );
335
392
  let called = false;
336
393
  const fetchImpl = (async () => {
337
394
  called = true;
338
395
  return jsonResponse({});
339
396
  }) as unknown as typeof fetch;
340
- expect(await resolveDraftForRequest(req, { env: ENV, fetchImpl })).toBeNull();
397
+ expect(
398
+ await resolveDraftForRequest(req, { env: ENV, fetchImpl }),
399
+ ).toBeNull();
341
400
  expect(called).toBe(false);
342
401
  });
343
402
  });
@@ -39,7 +39,8 @@ export interface DraftPointer {
39
39
  }
40
40
 
41
41
  /** Lowercase DNS hostname, at least two labels (a bare label can't match any domain). */
42
- const HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
42
+ const HOST_RE =
43
+ /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
43
44
  const PORT_RE = /^[0-9]{1,5}$/;
44
45
  const VERSION_RE = /^[A-Za-z0-9._-]{1,64}$/;
45
46
 
@@ -48,7 +49,9 @@ const VERSION_RE = /^[A-Za-z0-9._-]{1,64}$/;
48
49
  * back to published content. Requires EXACTLY one `@`: a naive split accepts
49
50
  * `a@b@c` and silently uses the first two segments.
50
51
  */
51
- export function parseDraftPointer(raw: string | null | undefined): DraftPointer | null {
52
+ export function parseDraftPointer(
53
+ raw: string | null | undefined,
54
+ ): DraftPointer | null {
52
55
  if (!raw) return null;
53
56
  const parts = raw.split("@");
54
57
  if (parts.length !== 2) return null;
@@ -71,6 +74,7 @@ export function parseDraftPointer(raw: string | null | undefined): DraftPointer
71
74
  */
72
75
  export const DEFAULT_PREVIEW_API_DOMAINS = [
73
76
  ".preview-studio.decocms.com",
77
+ ".preview-studio-stg.decocms.com",
74
78
  ".local.studio.decocms.com",
75
79
  ".localhost",
76
80
  ];
@@ -161,7 +165,9 @@ export function isDraftHostAllowed(
161
165
  env?: Record<string, string | undefined>,
162
166
  ): boolean {
163
167
  if (!host) return false;
164
- return readAllowedHosts(envOrProcess(env)).includes(host.trim().toLowerCase());
168
+ return readAllowedHosts(envOrProcess(env)).includes(
169
+ host.trim().toLowerCase(),
170
+ );
165
171
  }
166
172
 
167
173
  /**
@@ -170,7 +176,9 @@ export function isDraftHostAllowed(
170
176
  * unconfigured site never loses static/ISR rendering. The per-request host
171
177
  * match happens later, in `isDraftHostAllowed`.
172
178
  */
173
- export function isDraftPreviewEnabled(env?: Record<string, string | undefined>): boolean {
179
+ export function isDraftPreviewEnabled(
180
+ env?: Record<string, string | undefined>,
181
+ ): boolean {
174
182
  return readAllowedHosts(envOrProcess(env)).length > 0;
175
183
  }
176
184
 
@@ -179,7 +187,8 @@ function envOrProcess(
179
187
  ): Record<string, string | undefined> {
180
188
  return (
181
189
  env ??
182
- (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ??
190
+ (globalThis as { process?: { env?: Record<string, string | undefined> } })
191
+ .process?.env ??
183
192
  {}
184
193
  );
185
194
  }
@@ -277,7 +286,10 @@ export const DRAFT_COOKIE_NAME = "__deco_draft";
277
286
  export const DRAFT_QUERY_PARAM = "__draft";
278
287
 
279
288
  /** Read one cookie value out of a raw `Cookie:` header. */
280
- function readCookieValue(cookieHeader: string | null | undefined, name: string): string | null {
289
+ function readCookieValue(
290
+ cookieHeader: string | null | undefined,
291
+ name: string,
292
+ ): string | null {
281
293
  if (!cookieHeader) return null;
282
294
  for (const part of cookieHeader.split(";")) {
283
295
  const eq = part.indexOf("=");
@@ -331,7 +343,8 @@ export async function resolveDraftForRequest(
331
343
  if (readAllowedHosts(env).length === 0) return null;
332
344
  const pointer = draftPointerFromRequest(request);
333
345
  if (!pointer) return null;
334
- const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
346
+ const host =
347
+ request.headers.get("x-forwarded-host") ?? request.headers.get("host");
335
348
  if (!isDraftHostAllowed(host, env)) return null;
336
349
  return resolveDraftDecofile({ pointer, env, fetchImpl: options.fetchImpl });
337
350
  }
@@ -357,6 +370,9 @@ export function setDraftOverrideGetter(getter: DraftOverrideGetter): void {
357
370
  }
358
371
 
359
372
  /** The current request's draft blocks, if a binding registered one. */
360
- export function getRequestDraftOverride(): Record<string, unknown> | null | undefined {
373
+ export function getRequestDraftOverride():
374
+ | Record<string, unknown>
375
+ | null
376
+ | undefined {
361
377
  return getDraftOverride();
362
378
  }
@@ -78,7 +78,13 @@ export {
78
78
  withTracing,
79
79
  } from "../middleware/observability";
80
80
  // Worker-entry wrapper + adapter wiring
81
- export { instrumentWorker, type OtelOptions } from "./otel";
81
+ export {
82
+ bootObservability,
83
+ flushObservability,
84
+ instrumentWorker,
85
+ instrumentWorkflowRun,
86
+ type OtelOptions,
87
+ } from "./otel";
82
88
  // Direct-POST OTLP trace exporter (Phase 3 / D-12). Exported for sites
83
89
  // that need to wire a custom traces endpoint outside `instrumentWorker`,
84
90
  // and for the audit tooling that asserts framework spans are flowing.
@@ -14,7 +14,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
14
  import * as composite from "./composite";
15
15
  import * as logger from "./logger";
16
16
  import * as observability from "./observability";
17
- import { _resetBootStateForTests, instrumentWorker } from "./otel";
17
+ import {
18
+ _resetBootStateForTests,
19
+ flushObservability,
20
+ instrumentWorker,
21
+ instrumentWorkflowRun,
22
+ } from "./otel";
18
23
  import * as adapters from "./otelAdapters";
19
24
  import { createClickhouseCollectorAdapter } from "./otelAdapters/clickhouseCollector";
20
25
 
@@ -524,3 +529,108 @@ describe("instrumentWorker — OTLP/HTTP error-log channel wiring", () => {
524
529
  expect(calls).toHaveLength(0);
525
530
  });
526
531
  });
532
+
533
+ describe("instrumentWorkflowRun / flushObservability — Workflow entrypoints", () => {
534
+ beforeEach(() => {
535
+ _resetBootStateForTests();
536
+ });
537
+
538
+ afterEach(() => {
539
+ _resetBootStateForTests();
540
+ });
541
+
542
+ it("flushObservability resolves without throwing when no adapters are configured", async () => {
543
+ await expect(flushObservability()).resolves.toBeUndefined();
544
+ });
545
+
546
+ it("instrumentWorkflowRun returns fn's result and does not throw with no OTel env vars set", async () => {
547
+ const fn = vi.fn().mockResolvedValue("step-result");
548
+ const result = await instrumentWorkflowRun({}, undefined, fn);
549
+
550
+ expect(result).toBe("step-result");
551
+ expect(fn).toHaveBeenCalledOnce();
552
+ });
553
+
554
+ function makeFetchSpy() {
555
+ const calls: Array<{ url: string; body: string }> = [];
556
+ const impl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
557
+ calls.push({ url: String(input), body: String(init?.body ?? "") });
558
+ return new Response("{}", { status: 200 });
559
+ });
560
+ return { impl: impl as unknown as typeof fetch, calls };
561
+ }
562
+
563
+ it("rethrows fn's rejection but still flushes buffered metrics", async () => {
564
+ const { impl, calls } = makeFetchSpy();
565
+ const fn = vi.fn(async () => {
566
+ observability.recordRequestMetric("WORKFLOW", "/batch-product", 500, 9);
567
+ throw new Error("workflow step failed");
568
+ });
569
+ const env = { DECO_OTEL_METRICS_ENDPOINT: "https://ingest.test/v1/metrics" };
570
+
571
+ await expect(
572
+ instrumentWorkflowRun(env, { otlpMetricsFetchImpl: impl }, fn),
573
+ ).rejects.toThrow("workflow step failed");
574
+
575
+ expect(calls).toHaveLength(1);
576
+ expect(calls[0].url).toBe("https://ingest.test/v1/metrics");
577
+ });
578
+
579
+ it("still flushes when fn throws synchronously instead of returning a rejected promise", async () => {
580
+ const { impl, calls } = makeFetchSpy();
581
+ const fn = vi.fn(() => {
582
+ observability.recordRequestMetric("WORKFLOW", "/batch-product", 500, 9);
583
+ throw new Error("sync boom");
584
+ }) as unknown as () => Promise<string>;
585
+ const env = { DECO_OTEL_METRICS_ENDPOINT: "https://ingest.test/v1/metrics" };
586
+
587
+ await expect(
588
+ instrumentWorkflowRun(env, { otlpMetricsFetchImpl: impl }, fn),
589
+ ).rejects.toThrow("sync boom");
590
+
591
+ expect(calls).toHaveLength(1);
592
+ });
593
+
594
+ it("wires and flushes the OTLP metrics exporter, same as instrumentWorker's channel", async () => {
595
+ const { impl, calls } = makeFetchSpy();
596
+ const fn = vi.fn(async () => {
597
+ observability.recordRequestMetric("WORKFLOW", "/batch-product", 200, 12);
598
+ return "done";
599
+ });
600
+
601
+ const env = {
602
+ DECO_OTEL_METRICS_ENDPOINT: "https://ingest.test/v1/metrics",
603
+ } as unknown as Record<string, unknown>;
604
+
605
+ const result = await instrumentWorkflowRun(
606
+ env,
607
+ { serviceName: "workflow-smoke", otlpMetricsFetchImpl: impl },
608
+ fn,
609
+ );
610
+
611
+ expect(result).toBe("done");
612
+ expect(calls).toHaveLength(1);
613
+ expect(calls[0].url).toBe("https://ingest.test/v1/metrics");
614
+ });
615
+
616
+ it("forces the flush past the per-isolate cooldown across consecutive replay legs", async () => {
617
+ // Regression test: instrumentWorkflowRun forces its exit-time flush past
618
+ // the ~5s default cooldown each OTLP adapter otherwise enforces, since a
619
+ // Workflow run() only gets one flush attempt per leg — no next request
620
+ // to retry on like a fetch handler has.
621
+ const { impl, calls } = makeFetchSpy();
622
+ const env = { DECO_OTEL_METRICS_ENDPOINT: "https://ingest.test/v1/metrics" };
623
+ const opts = { otlpMetricsFetchImpl: impl };
624
+
625
+ await instrumentWorkflowRun(env, opts, async () => {
626
+ observability.recordRequestMetric("WORKFLOW", "/leg-1", 200, 5);
627
+ });
628
+ // A second replay leg immediately after — well within the default
629
+ // cooldown window — must still flush, not get silently skipped.
630
+ await instrumentWorkflowRun(env, opts, async () => {
631
+ observability.recordRequestMetric("WORKFLOW", "/leg-2", 200, 5);
632
+ });
633
+
634
+ expect(calls).toHaveLength(2);
635
+ });
636
+ });
package/src/sdk/otel.ts CHANGED
@@ -446,34 +446,98 @@ export function instrumentWorker(
446
446
  // ctx.waitUntil so no POST blocks the response. Each exporter
447
447
  // throttles itself per isolate — calling on every request is
448
448
  // cheap; the network only fires when the cooldown elapses or
449
- // the buffer fills.
449
+ // the buffer fills. Skip the waitUntil call entirely when no
450
+ // adapter is configured (nothing to flush).
450
451
  const state = getBootState();
451
- if (state.otlpMeter) {
452
+ if (state.otlpMeter || state.otlpLog || state.otlpTracer) {
452
453
  try {
453
- ctx.waitUntil(state.otlpMeter.flush());
454
+ ctx.waitUntil(flushObservability());
454
455
  } catch {
455
456
  /* ctx.waitUntil throwing is benign — never block the response */
456
457
  }
457
458
  }
458
- if (state.otlpLog) {
459
- try {
460
- ctx.waitUntil(state.otlpLog.flush());
461
- } catch {
462
- /* swallow */
463
- }
464
- }
465
- if (state.otlpTracer) {
466
- try {
467
- ctx.waitUntil(state.otlpTracer.flush());
468
- } catch {
469
- /* swallow */
470
- }
471
- }
472
459
  }
473
460
  },
474
461
  };
475
462
  }
476
463
 
464
+ /**
465
+ * Drains the OTLP metrics + error-log + traces buffers (whichever are
466
+ * configured for this isolate). Extracted out of `instrumentWorker`'s
467
+ * `finally` block so callers without a `{fetch}`/`ctx.waitUntil` lifecycle —
468
+ * e.g. Cloudflare Workflow `run()` handlers — can flush explicitly too. See
469
+ * `instrumentWorkflowRun` below.
470
+ *
471
+ * Each adapter's `flush()` is throttled by a per-isolate cooldown (default
472
+ * 5s) so a busy fetch handler doesn't fire a POST per request — pass
473
+ * `force: true` to bypass that cooldown for callers that only get one flush
474
+ * attempt with no next request to retry on (Workflow `run()` exit).
475
+ */
476
+ export async function flushObservability(force = false): Promise<void> {
477
+ const state = getBootState();
478
+ await Promise.allSettled([
479
+ state.otlpMeter?.flush(force),
480
+ state.otlpLog?.flush(force),
481
+ state.otlpTracer?.flush(force),
482
+ ]);
483
+ }
484
+
485
+ /**
486
+ * Boots observability (same env-var-driven wiring `instrumentWorker` uses)
487
+ * and runs `fn`, flushing on the way out — the Workflow-entrypoint
488
+ * equivalent of `instrumentWorker`. Cloudflare Workflows have no `{fetch}` /
489
+ * `ctx.waitUntil` lifecycle, so this awaits the flush directly instead.
490
+ *
491
+ * Deliberately does NOT open a root span around `fn()`. Code in a
492
+ * `WorkflowEntrypoint.run(event, step)` outside `step.do`/`step.sleep`
493
+ * re-executes on every hibernate/wake replay — a span opened here would get
494
+ * a fresh random trace ID on every replay leg, producing disconnected
495
+ * traces instead of one coherent run. Create spans for individual
496
+ * `step.do(...)` callbacks with `withTracing` instead (each step only truly
497
+ * runs once, since `step.do` memoizes across replays), tagging them with
498
+ * `event.instanceId` so a backend can group replay legs under one logical
499
+ * run.
500
+ *
501
+ * The exit-time flush is `force`d (bypasses the per-isolate cooldown other
502
+ * `flush()` callers respect) — a Workflow `run()` invocation gets exactly
503
+ * one flush attempt on the way out, unlike a fetch handler where the next
504
+ * request's `ctx.waitUntil(flush())` can pick up whatever a cooldown-skipped
505
+ * flush left behind. Without forcing, a workflow with several replay legs in
506
+ * quick succession could have its final leg's flush silently skipped by the
507
+ * cooldown, losing that leg's buffered spans/metrics if the isolate is torn
508
+ * down before the next wake.
509
+ *
510
+ * @example
511
+ * ```ts
512
+ * async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
513
+ * return instrumentWorkflowRun(this.env, undefined, async () => {
514
+ * await step.do("fetch-product", () =>
515
+ * withTracing("deco.workflow.step", () => fetchProduct(), {
516
+ * "workflow.instance_id": event.instanceId,
517
+ * "workflow.step": "fetch-product",
518
+ * }),
519
+ * );
520
+ * });
521
+ * }
522
+ * ```
523
+ */
524
+ export async function instrumentWorkflowRun<T>(
525
+ env: Record<string, unknown>,
526
+ options: OtelOptions | undefined,
527
+ fn: () => Promise<T>,
528
+ ): Promise<T> {
529
+ configureTracer(buildOtelApiTracer());
530
+ bootObservability(options ?? {}, env);
531
+ try {
532
+ // Awaiting inside this try (rather than `return fn().finally(...)`)
533
+ // ensures a synchronous throw from `fn` — not just a rejected promise —
534
+ // still reaches the `finally` below instead of skipping it.
535
+ return await fn();
536
+ } finally {
537
+ await flushObservability(true);
538
+ }
539
+ }
540
+
477
541
  /**
478
542
  * Build the legacy `@opentelemetry/api` global-tracer bridge. Stays a
479
543
  * no-op when no global TracerProvider is registered — same outcome as
@@ -639,7 +703,18 @@ function patchConsole(state: BootState): void {
639
703
  console.debug = (...args: unknown[]) => forward("debug", args);
640
704
  }
641
705
 
642
- function bootObservability(opts: OtelOptions, env: Record<string, unknown>): void {
706
+ /**
707
+ * Boots the observability stack for the current isolate: resolves the
708
+ * `DECO_OTEL` on/off/auto switch, builds the resource-attribute floor
709
+ * (service name/version/instance, deployment environment, cloud platform),
710
+ * and — gated per env var — wires the AE meter and OTLP metrics/logs/traces
711
+ * adapters. Idempotent per isolate (`BootState.booted` guards re-entry).
712
+ *
713
+ * Exported so callers with a lifecycle `instrumentWorker` doesn't fit
714
+ * (Cloudflare Workflows — see `instrumentWorkflowRun`) can boot the same
715
+ * stack directly.
716
+ */
717
+ export function bootObservability(opts: OtelOptions, env: Record<string, unknown>): void {
643
718
  const state = getBootState();
644
719
  if (state.booted) return;
645
720
 
@@ -107,8 +107,8 @@ export interface OtlpHttpLogOptions {
107
107
 
108
108
  export interface OtlpHttpLog {
109
109
  adapter: LoggerAdapter;
110
- /** Force a flush, subject to the per-isolate cooldown. */
111
- flush(): Promise<void>;
110
+ /** Flush, subject to the per-isolate cooldown unless `force` is true. */
111
+ flush(force?: boolean): Promise<void>;
112
112
  /** Pending log record count. For tests + audit. */
113
113
  pendingRecordCount(): number;
114
114
  }
@@ -324,12 +324,12 @@ export function createOtlpHttpLogAdapter(options: OtlpHttpLogOptions): OtlpHttpL
324
324
  }
325
325
  }
326
326
 
327
- async function flush(): Promise<void> {
327
+ async function flush(force = false): Promise<void> {
328
328
  if (inflight) return inflight;
329
329
 
330
330
  const elapsed = now() - lastFlushAt;
331
331
  const overCap = buffer.length >= maxBuffer;
332
- if (!overCap && elapsed < minFlushIntervalMs) return;
332
+ if (!force && !overCap && elapsed < minFlushIntervalMs) return;
333
333
 
334
334
  inflight = doFlush().finally(() => {
335
335
  lastFlushAt = now();
@@ -93,8 +93,8 @@ export interface OtlpHttpMeter extends MeterAdapter {
93
93
  gaugeSet(name: string, value: number, labels?: Labels): void;
94
94
  /** Always defined on this adapter — declared required to drop the `?.` at call sites. */
95
95
  histogramRecord(name: string, value: number, labels?: Labels): void;
96
- /** Force a flush, subject to the per-isolate cooldown. */
97
- flush(): Promise<void>;
96
+ /** Flush, subject to the per-isolate cooldown unless `force` is true. */
97
+ flush(force?: boolean): Promise<void>;
98
98
  /** Pending datapoint count across all metric kinds. For tests + audit. */
99
99
  pendingDatapointCount(): number;
100
100
  }
@@ -341,7 +341,7 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
341
341
  }
342
342
  }
343
343
 
344
- async function flush(): Promise<void> {
344
+ async function flush(force = false): Promise<void> {
345
345
  // If a flush is in flight, reuse it — concurrent requests should not
346
346
  // pile up POSTs. The in-flight POST already snapshotted the buffer at
347
347
  // its enqueue time; new datapoints land in the buffer and will go out
@@ -350,8 +350,11 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
350
350
 
351
351
  const elapsed = now() - lastFlushAt;
352
352
  const overCap = pendingDatapointCount() >= maxBuffer;
353
- if (!overCap && elapsed < minFlushIntervalMs) {
354
- // Cooldown not elapsed and buffer is not at the cap — skip.
353
+ if (!force && !overCap && elapsed < minFlushIntervalMs) {
354
+ // Cooldown not elapsed and buffer is not at the cap — skip. `force`
355
+ // bypasses this for callers with no next-request opportunity to
356
+ // retry the flush (e.g. a Cloudflare Workflow's `run()` exit — see
357
+ // `instrumentWorkflowRun` in `otel.ts`).
355
358
  return;
356
359
  }
357
360
 
@@ -233,8 +233,8 @@ export interface OtlpHttpTracerOptions {
233
233
  }
234
234
 
235
235
  export interface OtlpHttpTracer extends TracerAdapter {
236
- /** Drain the buffer (subject to cooldown). */
237
- flush(): Promise<void>;
236
+ /** Drain the buffer, subject to cooldown unless `force` is true. */
237
+ flush(force?: boolean): Promise<void>;
238
238
  /** For tests + the audit channel. */
239
239
  pendingSpanCount(): number;
240
240
  /**
@@ -433,11 +433,11 @@ export function createOtlpHttpTracerAdapter(options: OtlpHttpTracerOptions): Otl
433
433
  }
434
434
  }
435
435
 
436
- async function flush(): Promise<void> {
436
+ async function flush(force = false): Promise<void> {
437
437
  if (inflight) return inflight;
438
438
  const elapsed = now() - lastFlushAt;
439
439
  const overCap = spans.length >= maxBuffer;
440
- if (!overCap && elapsed < minFlushIntervalMs) return;
440
+ if (!force && !overCap && elapsed < minFlushIntervalMs) return;
441
441
  inflight = doFlush().finally(() => {
442
442
  lastFlushAt = now();
443
443
  inflight = null;