feat(workflow): emit OpenTelemetry spans for runs and nodes - #3888
Conversation
The workflow executor opened no spans, so agent spans arrived orphaned with no workflow structure above them. Callers could not tell which node produced a tool call, and the only hook available fired its start and complete halves in different async scopes -- so a span could not be held across the pair without reaching into the OTel context manager's private storage. Open a workflow.run span per execution and a workflow.node <id> span per node, using the same withSpan the agent runtime uses so agent work nests under its node through ordinary context propagation. The node span sits at the DAG dispatch layer rather than the step executor, so all seven node types are covered. workflow.run_id is always the root, backend-persisted run id: parallel, branch and sub-workflow nodes synthesise run records whose ids are never persisted, so the root id is threaded explicitly rather than read locally. It is a parameter and not a field because the DAG executor is shared across concurrent runs. Retries stay inside one node span, so each retry emits a span event with the attempt, delay and error -- otherwise a node that failed for minutes and then succeeded renders as one uneventful span. Span attributes carry identifiers only, never resolved step input or output. onStepStart and onStepComplete are unchanged.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
📦 Client bundle boundary
A server module in a client graph aborts hydration in the browser. New leaks fail CI; known leaks are tracked in |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5468058261
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Review found that spans were always UNSET. Workflow failures are returned as a failed node state rather than thrown, so withSpan's catch never ran, and the async withSpan -- unlike withSpanSync -- never sets OK either. A failed run was indistinguishable from a successful one, leaving "which node failed" unanswerable from a trace. Node spans now carry workflow.node.status and set ERROR with the node's error text; the run span sets ERROR from the run control's onError callback. Also from review: - The composite retry path drew calculateRetryDelay twice, and it applies random jitter, so the recorded delay was never the delay slept. Draw once. - Composite retry telemetry had no coverage: deleting both call sites left every test green. Covered by a map node with retry. - workflow.run is not a trace root when the caller is traced -- it joins the caller's trace. The comment, README and a test all claimed otherwise. Behaviour kept, since a run belongs under the request that started it; claims corrected and the real behaviour pinned by a test. - README documents loop iteration cardinality alongside map. - The payload test scanned attributes but not event attributes, where the one free-form error string lives.
|
Updated after an independent review pass. The blocker it found: every span was Two more real defects:
One claim of mine was simply wrong. I documented Also: README covers loop-iteration cardinality alongside map, and the payload test now scans event attributes too — where the one free-form error string lives. Left open deliberately: a retried composite node emits N identically-named sibling child spans with no attempt marker. Fixing that means encoding attempt into generated child identity, which reopens the span-naming decision rather than settling it in a review. Tests 7 → 10. |
Retry spans need enough signal to explain retry behavior without exporting user-provided failure messages. The retry event now carries a bounded error classification shared by step and composite execution while preserving the recorded delay and attempt metadata. Constraint: Workflow telemetry must preserve the no-payload rule and avoid raw error messages.\nRejected: Redact and keep workflow.node.error | sanitized free text can still expose customer-specific failure detail.\nConfidence: high\nScope-risk: narrow\nDirective: Do not add free-form workflow retry error fields without a no-payload regression test.\nTested: deno test --preload=src/testing/preload.ts --no-check --allow-all src/workflow/executor/workflow-tracing.test.ts\nTested: deno test --preload=src/testing/preload.ts --no-check --allow-all src/workflow/executor/step-executor.test.ts src/workflow/executor/dag/index.test.ts src/workflow/executor/dag-executor.test.ts src/workflow/executor/workflow-executor.test.ts src/workflow/executor/workflow-tracing.test.ts\nTested: deno lint src/workflow/executor/retry-policy.ts src/workflow/executor/step-executor.ts src/workflow/executor/dag/composite-node-execution.ts src/workflow/executor/workflow-tracing.test.ts\nTested: deno check src/workflow/executor/retry-policy.ts src/workflow/executor/step-executor.ts src/workflow/executor/dag/composite-node-execution.ts src/workflow/executor/workflow-tracing.test.ts\nTested: deno fmt --check src/workflow/executor/retry-policy.ts src/workflow/executor/step-executor.ts src/workflow/executor/dag/composite-node-execution.ts src/workflow/executor/workflow-tracing.test.ts\nTested: deno test --preload=src/testing/preload.ts --no-check --allow-all tests/docs/guide-contracts.test.ts tests/docs/guide-content.test.ts\nTested: git diff --check\nNot-tested: Full repository test suite
Re-review found the jitter fix had no guard: reverting it to draw calculateRetryDelay twice left every test green, because the retry test read the attempt number but never the recorded delay, and at initialDelay 1 the jitter band is too narrow for any value assertion to discriminate. Stub Math.random with alternating draws at initialDelay 100 so a second draw is forced to differ: the slept value is 90, a fresh draw would report 110. Verified by mutation -- the test fails with the double-draw restored. Also document in the extension README that a cancelled run is not a failure (the in-flight node span ends ERROR with the reason, workflow.run stays unset), and that composite retry attempts appear as sibling spans told apart by status.
The generated reference embeds source line numbers, so adding the retry telemetry import to step-executor shifted getWorkflowTenant by one line and left docs/api-reference/veryfront/workflow.md stale. docs:api-reference:check gates ci (lint), so this would have failed CI. Regenerated with deno task docs.
|
Rebased onto @kojiwakayama's It would have failed CI as pushed. The generated API reference embeds source line numbers, so the added import shifted The delay guard is weaker than it looks. On the substance — your change supersedes mine and I've kept it. I had put the raw error message on retry events. Review flagged that redaction is key-name driven, so free prose like Branch now: 14 tracing tests pass. Still open for a human: whether |
|
Rebased onto It would have failed CI as pushed. The generated API reference embeds source line numbers, so the added import shifted The delay guard is weaker than it looks. On the substance — that change supersedes mine and I kept it. I had put the raw error message on retry events. Review flagged that redaction is key-name driven, so free prose like Branch now: 14 tracing tests pass. Still open for a human: whether |
Closes #3871.
Problem
The workflow executor opened no OpenTelemetry spans. The agent runtime instruments itself
(
agent.generate,agent.execution_loop,agent.tool_execute), so with@veryfront/ext-observability-opentelemetryregistered you got a flat scatter ofagent-internal spans with no workflow structure above them — no way to see which node a tool
call belonged to, or how long a run took.
The only hook available was
onStepStart(nodeId, input)/onStepComplete(nodeId, output),and it is not enough to build tracing on:
onStepStartfires inside the step's asyncexecution scope while
onStepCompletefires after the awaited body resolves, so a callercannot hold a span across the pair through normal context propagation. The reporter had to
reach into the OTel context manager's private
_asyncLocalStorageand callenterWith()directly.
The first commit here is a failing test that reproduces exactly that: one workflow, one
agent-style span opened inside the step, and the captured trace contains
["agent.generate"]— a single orphan, no parent.What changed
workflow.runspan wraps each execution, inWorkflowExecutor.executeRun— the singlepoint both
start()andresume()funnel through.workflow.node <id>span wraps each node, inDAGExecutor.executeNode. Dispatch movedinto an extracted
dispatchNodeso the span brackets the strategy call.StepExecutorandexecuteCompositeNodeWithPolicy),plus a final
workflow.node.attemptsattribute.addActiveSpanEventexport inotlp-setup.ts, mirroring the existingsetActiveSpanAttributes.No public API change.
onStepStart/onStepCompletekeep their exact signatures andfiring order. They turned out not to need changing:
withSpanactivates its span through thecontext manager, so anything opened inside the node body — every agent span — nests
automatically. The
enterWith()workaround should now be deletable.Design decisions worth knowing
The span opens at the DAG dispatch layer, not the step executor. The issue named
step-executor, but that only handlesstepnodes; amaporparallelnode would haveproduced children with no parent.
executeNodeis the one place all seven node types passthrough.
One trace per execution attempt, not per run. A run that pauses at a wait node or a
pending approval resumes later, possibly in another process; a span cannot stay open across
that. Resumed executions start a new trace and correlate through the
workflow.run_idattribute, which is on every span. Persisting trace context on the run and joining executions
with OTel span links is the intended follow-up — additive, and deliberately not here.
workflow.run_idis always the root, backend-persisted run id. Parallel, branch, andsub-workflow nodes each synthesize a run record with a generated id that is never persisted
and cannot be looked up. Those ids would silently break correlation, so
rootRunIdisthreaded explicitly rather than read from the local
run. It is a parameter and not aninstance field because
DAGExecutoris shared across concurrent runs, where a field wouldrace. A sub-workflow's synthetic identity is recorded as
workflow.sub_run_id/workflow.sub_workflow_idinstead.Retries produce one span plus events, not a span per attempt. Both retry loops sit inside
the node span, so without events a node that failed for four minutes and then succeeded in
two seconds would render as one uneventful green span. The event carries attempt number,
backoff delay, and error message.
Span attributes carry identifiers only — never resolved step input or output. The issue
asked for input/output on spans. Agent spans in this codebase carry
agent.id,agent.modeland timestamps and never message content; workflow inputs routinely carry customer data and
spans are exported to third-party backends. A test asserts no attribute contains a step
payload.
No new configuration.
withSpanalready no-ops when the extension is absent, and tracingis already gated by the existing OTel environment variables.
Node ids are used verbatim in span names. A
mapover N items therefore produces Ndistinct span names. This is a deliberate trade-off for a trace that reads at a glance in
Jaeger, which is the reporter's stated target; backends that aggregate by span name will see
high series counts from map fan-out. Documented in the extension README along with the span
volume hazard, since no framework-level cap is applied — sampling is the operator's call.
Reading the diff
workflow-executor.tsreports ~114 changed lines, but only four are real — the rest isreindentation from wrapping the existing call in
withSpan. Reviewing withgit diff -won that file shows the actual change.Tests
New
src/workflow/executor/workflow-tracing.test.ts, 7 cases. The harness mirrorswireTracingShiminsrc/server/bootstrap.tsexactly — real@opentelemetry/api, realAsyncLocalStorageContextManager, real tracer provider. That matters: the one pre-existingspan test stubs context propagation with
with: (_ctx, fn) => fn(), which can never observeparentage. A stub here would have been a false green.
Covered: agent-span-nests-under-node and node-under-run with matching trace ids; composite
node types each emitting a span and every span carrying the root run id; retry event and
attempt count; skipped node flagged and replayed node emitting nothing; no payload leakage;
unchanged behaviour with no extension wired; and an import-parity guard.
That last one is worth calling out. Two different
withSpanfunctions are exported from theobservability layer — one from
otlp-setup, one from the tracing-manager index. The agentruntime uses the former. If workflow code drifts to the other, spans still appear and span
counts still pass, but agent work silently stops nesting — the exact bug this PR fixes. The
guard pins that both sides import the same one.
Not in this PR
workflow.node.index/workflow.node.iterationattributes for map and loop children. Theindex is already in the span name given the naming decision above, so these were dropped
rather than added speculatively.
Verification
deno task lint:ci— full gate.deno test src/workflow/— 74 files pass.src/workflow/blob/veryfront-cloud-storage.test.tsfails, but it fails identically on a clean
origin/maincheckout; it is pre-existing andunrelated to this change.