Skip to content

feat(blocks): add instrumentWorkflowRun for Cloudflare Workflow tracing - #414

Open
hugo-ccabral wants to merge 1 commit into
mainfrom
otel-workflow-instrumentation
Open

feat(blocks): add instrumentWorkflowRun for Cloudflare Workflow tracing#414
hugo-ccabral wants to merge 1 commit into
mainfrom
otel-workflow-instrumentation

Conversation

@hugo-ccabral

@hugo-ccabral hugo-ccabral commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • instrumentWorker assumes a {fetch}/ctx.waitUntil request lifecycle that Cloudflare Workflow run(event, step) entrypoints don't have, so Workflow-heavy consumers currently have no way to reuse the observability stack for background jobs.
  • Exports bootObservability (already transport/lifecycle-agnostic — it never itself calls ctx.waitUntil) and extracts flushObservability() out of instrumentWorker's finally block into its own function, preserving the existing "only call ctx.waitUntil when an adapter is actually configured" behavior.
  • Adds instrumentWorkflowRun<T>(env, options, fn): boots observability and runs fn, flushing with a plain await on the way out (no ctx.waitUntil — Workflows have no such hook and aren't latency-sensitive). Deliberately does not open a root span around fn(), since code outside step.do/step.sleep in run() re-executes on every hibernate/wake replay — a span opened there would get a new random trace ID per replay leg instead of one coherent trace. Consumers should span individual step.do(...) callbacks with the existing withTracing, tagged with event.instanceId.

Test plan

  • bun run --filter='./packages/blocks' typecheck passes
  • packages/blocks/src/sdk/otel.test.ts — 28/28 passing, including 4 new tests covering flushObservability's no-op case, instrumentWorkflowRun's return/rethrow/flush behavior, and OTLP metrics wiring through the new entrypoint
  • Full packages/blocks suite run — no new failures (23 pre-existing failures in cms/loader.test.ts/layoutCacheRace.test.ts are unrelated URLPattern Web API gaps in this local Bun/Node runtime, not caused by this change)

🤖 Generated with Claude Code


Summary by cubic

Adds instrumentWorkflowRun to enable OTLP metrics/logs/traces in Cloudflare Workflow run(event, step) without ctx.waitUntil. Also exports bootObservability and a new flushObservability for reuse across non-Worker entrypoints.

  • New Features

    • instrumentWorkflowRun<T>(env, options, fn): boots observability, runs fn, and flushes with await on exit. No root span is opened; span individual step.do(...) with withTracing and tag event.instanceId.
    • Exported bootObservability and flushObservability() for direct control in Workflow and other non-fetch contexts.
  • Refactors

    • Extracted flushObservability() and updated instrumentWorker to call it via ctx.waitUntil only when any adapter is configured (meter/log/tracer).

Written for commit 949ae42. Summary will update on new commits.

Review in cubic

instrumentWorker assumes a {fetch}/ctx.waitUntil lifecycle that Workflow
run(event, step) entrypoints don't have. Exports bootObservability and
extracts flushObservability from instrumentWorker's finally block so
Workflow entrypoints can boot the same observability stack and flush
explicitly (a plain await, no ctx.waitUntil) via the new
instrumentWorkflowRun helper.
@hugo-ccabral
hugo-ccabral requested a review from a team August 1, 2026 10:10

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/blocks/src/sdk/otel.test.ts">

<violation number="1" location="packages/blocks/src/sdk/otel.test.ts:554">
P3: The test name promises "and still flushes", but the body only asserts the rejection and configures no OTLP adapter, so the flush-on-error behavior it claims to cover is never actually verified. Either rename it to reflect that it only checks error propagation, or configure a metrics fetch impl and assert the POST fires after rejection as the name implies.</violation>
</file>

<file name="packages/blocks/src/sdk/otel.ts">

<violation number="1" location="packages/blocks/src/sdk/otel.ts:517">
P2: A synchronous exception from `fn` skips the flush and escapes synchronously, so buffered telemetry from earlier workflow work can be lost. Start the callback in a promise chain so both synchronous throws and rejected promises reach the same `finally`.</violation>

<violation number="2" location="packages/blocks/src/sdk/otel.ts:517">
P2: Workflow runs get one explicit flush on the way out, but the OTLP meter (and by extension the same per-isolate pattern) enforces a 5s flush cooldown in `flush()`. When a workflow replays legs within that window, the final `await flushObservability()` is a silent no-op, so spans/metrics logged in the last leg can be lost when the isolate is torn down — exactly the durability this `finally` flush is meant to provide. Consider exposing a force/ignore-cooldown flush path (or raising the cap) for the Workflow entrypoint so the on-exit flush actually ships buffered data.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

): Promise<T> {
configureTracer(buildOtelApiTracer());
bootObservability(options ?? {}, env);
return fn().finally(() => flushObservability());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A synchronous exception from fn skips the flush and escapes synchronously, so buffered telemetry from earlier workflow work can be lost. Start the callback in a promise chain so both synchronous throws and rejected promises reach the same finally.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/blocks/src/sdk/otel.ts, line 517:

<comment>A synchronous exception from `fn` skips the flush and escapes synchronously, so buffered telemetry from earlier workflow work can be lost. Start the callback in a promise chain so both synchronous throws and rejected promises reach the same `finally`.</comment>

<file context>
@@ -446,34 +446,77 @@ export function instrumentWorker(
+): Promise<T> {
+  configureTracer(buildOtelApiTracer());
+  bootObservability(options ?? {}, env);
+  return fn().finally(() => flushObservability());
+}
+
</file context>
Suggested change
return fn().finally(() => flushObservability());
return Promise.resolve().then(fn).finally(() => flushObservability());

): Promise<T> {
configureTracer(buildOtelApiTracer());
bootObservability(options ?? {}, env);
return fn().finally(() => flushObservability());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Workflow runs get one explicit flush on the way out, but the OTLP meter (and by extension the same per-isolate pattern) enforces a 5s flush cooldown in flush(). When a workflow replays legs within that window, the final await flushObservability() is a silent no-op, so spans/metrics logged in the last leg can be lost when the isolate is torn down — exactly the durability this finally flush is meant to provide. Consider exposing a force/ignore-cooldown flush path (or raising the cap) for the Workflow entrypoint so the on-exit flush actually ships buffered data.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/blocks/src/sdk/otel.ts, line 517:

<comment>Workflow runs get one explicit flush on the way out, but the OTLP meter (and by extension the same per-isolate pattern) enforces a 5s flush cooldown in `flush()`. When a workflow replays legs within that window, the final `await flushObservability()` is a silent no-op, so spans/metrics logged in the last leg can be lost when the isolate is torn down — exactly the durability this `finally` flush is meant to provide. Consider exposing a force/ignore-cooldown flush path (or raising the cap) for the Workflow entrypoint so the on-exit flush actually ships buffered data.</comment>

<file context>
@@ -446,34 +446,77 @@ export function instrumentWorker(
+): Promise<T> {
+  configureTracer(buildOtelApiTracer());
+  bootObservability(options ?? {}, env);
+  return fn().finally(() => flushObservability());
+}
+
</file context>

expect(fn).toHaveBeenCalledOnce();
});

it("rethrows fn's error and still flushes", async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The test name promises "and still flushes", but the body only asserts the rejection and configures no OTLP adapter, so the flush-on-error behavior it claims to cover is never actually verified. Either rename it to reflect that it only checks error propagation, or configure a metrics fetch impl and assert the POST fires after rejection as the name implies.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/blocks/src/sdk/otel.test.ts, line 554:

<comment>The test name promises "and still flushes", but the body only asserts the rejection and configures no OTLP adapter, so the flush-on-error behavior it claims to cover is never actually verified. Either rename it to reflect that it only checks error propagation, or configure a metrics fetch impl and assert the POST fires after rejection as the name implies.</comment>

<file context>
@@ -524,3 +529,60 @@ describe("instrumentWorker — OTLP/HTTP error-log channel wiring", () => {
+    expect(fn).toHaveBeenCalledOnce();
+  });
+
+  it("rethrows fn's error and still flushes", async () => {
+    const fn = vi.fn().mockRejectedValue(new Error("workflow step failed"));
+
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants