@decocms/blocks 7.29.0 → 7.31.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.29.0",
3
+ "version": "7.31.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -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;