Skip to content

feat(workflows): task execution telemetry and structured status - #187

Draft
Tsuyoshi Ushio (TsuyoshiUshio) wants to merge 9 commits into
tsuyoshiushio-workflow-timeout-continuationfrom
tsuyoshiushio-workflow-execution-telemetry
Draft

feat(workflows): task execution telemetry and structured status#187
Tsuyoshi Ushio (TsuyoshiUshio) wants to merge 9 commits into
tsuyoshiushio-workflow-timeout-continuationfrom
tsuyoshiushio-workflow-execution-telemetry

Conversation

@TsuyoshiUshio

@TsuyoshiUshio Tsuyoshi Ushio (TsuyoshiUshio) commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Stack

This is PR 3 of 3 in the decomposition of #170.

Order PR Branch Base
1 #185 — Durable native Activity retry tsuyoshiushio-workflow-native-retry main
2 #186 — per-attempt timeout + continue_on_error tsuyoshiushio-workflow-timeout-continuation tsuyoshiushio-workflow-native-retry
3 this PR — execution observability tsuyoshiushio-workflow-execution-telemetry tsuyoshiushio-workflow-timeout-continuation

The base is deliberately #186's branch, not main, so the GitHub diff shows
only this slice. Review #185#186 → this PR in order.

Rebased 2026-08-29 onto #186's new head f78d3135 (which now includes
#185's _await_wave / _first_wave_failure fix). The nine PR-3 commits are
preserved. See Rebase notes.

What this adds

The task execution policy is only useful if an operator can see what it did.
Two surfaces carry that, split along the Durable replay boundary.

1. Activity-side telemetry

Each delivery of a policy-aware task records one workflow.task.activity span
from the Activity — never from the orchestrator, which re-executes from
history on every replay and would re-emit the span for work that already
happened. Deliveries a policy denies before the handler runs (authorization,
unregistered target, malformed persisted input) record the same span, so the
deliveries an operator most needs to see are not the ones without telemetry.

Attributes are af.workflow_task.*: workflow_id, task_id,
node_instance_id, target_type, target_name, max_attempts, timeout_ms,
continue_on_error, retry_driver, plus the terminal outcome_kind,
error_code, and disposition.

disposition reports what the Activity didreturn_result,
request_durable_retry, return_failure, abort — never what Durable will
decide, which the Activity cannot see. The attempt number stays absent for the
same reason (FRD 0004 Decision 73).

Two counters (azure_functions_agents.workflow_task.attempts / .outcomes)
carry a deliberately narrow dimension set. Workflow ids, node instance ids,
target names, and handler-authored error codes would create one time series per
task attempt, so they stay on the span.

_ensure_metrics now creates each instrument in its own guard: a meter that
rejected one instrument previously nulled every counter in the runtime.

2. Structured custom_status schema version 3

A dynamically controlled plan whose persisted input froze at least one
execution policy publishes schema_version: 3. It adds
retry_driver: "durable", the counts pending / failed / failed_continued,
the per-node max_attempts, and the sanitized last_failure_kind /
last_error_code of a continued failure.

The node whose failure ends a workflow is now marked and published before the
exception propagates, so the last status names it instead of freezing on the
pre-failure snapshot.

3. UI

formatWorkflowStatus accepts version 3 and renders the new counts, the frozen
budget, and a continued failure's code. Two small CSS additions let a longer
status line wrap inside the card instead of overflowing it. Nothing else in the
UI changed.

Compatibility

  • Version 2 is byte-identical for a plan that froze no execution policy —
    same keys, same values, same materialized_total semantics. Version 3 is
    gated on the persisted orchestration input alone, so an older history replays
    unchanged and the version cannot differ between replays.
  • A version 2-only client is unaffected: it sees an unknown
    schema_version and degrades to compact JSON, exactly as it already must for
    any future version.
  • A task with no frozen policy is untouched: no span, legacy
    {"id", "result"} Activity envelope, string custom_status for static plans.
  • No new scheduler state. failed_continued is a reporting projection.
    _aggregate_dynamic_node copies the instance state into the persisted
    {index, status, result} aggregate, so a continued instance stays
    completed and carries private metadata only the status reads. Every
    terminal-state predicate in the scheduler is unchanged.
  • Rollback still follows FRD 0004 Decision 81: drain or terminate in-flight
    workflows started by the newer runtime.

Rebase notes

#186 was rebased onto #185's f7bc03e7 ("select over wave tasks instead of
nesting composites"), which replaced task_all(wave) with _await_wave /
_first_wave_failure. This branch was replayed onto the new head with
git rebase --onto + --force-with-lease; all nine commits are intact and no
nested task_all was reintroduced.

Two conflicts, both resolved toward the new flow:

  • engine.py, static scheduler_decode_wave_failure now receives the
    single failure _first_wave_failure selected rather than the whole wave
    result.
  • docs/frds/0004-dynamic-workflows.md — the two slices' review entries are
    both kept.

The new flow also improved one behavior. _await_wave reports each node's own
outcome in wave order, so the instance that ends a workflow is now identified
positionally instead of by decoding a sanitized marker. An opaque Durable
failure is therefore attributed just as precisely as a classified one (only the
reason is still left to the Durable error), and _decode_wave_failure keeps
its original single-value signature — so this PR no longer touches it at all.
Decision 88 and its test were updated accordingly; the engine diff shrank by 15
lines.

The PR-3 orchestrator test harness was ported to the new
context.task_any([cancel, *wave_tasks]) selection loop, matching the harness
#186 already uses.

Explicitly out of scope

Read-only Skill approval, extension bundle sweeps, unrelated runner behavior,
unrelated UI cleanup, further SSE/streaming client changes, sample rewrites,
plans/ ExecPlan files, uv.lock churn, and all other #170 collateral. No
change to config/schema.py, so front-matter-reference.md is unaffected.
status_envelope() already passes custom_status through unchanged, so no
public serialization change was needed.

Reviews

  • Architecture review (independent, pre-implementation) raised 5 blocking
    findings: a new failed_continued scheduler state would have changed the
    shipped aggregate contract; the slice claimed exhaustion observability the
    runtime did not have; counters reused high-cardinality span attributes;
    failure spans would have closed as successful (failures are returned) while
    the private retry marker would have been recorded as an exception on every
    attempt; and the FRD did not yet record the contract. All resolved —
    Decisions 82–89.
  • Code review (independent, post-implementation) raised one medium finding:
    the early-failure path fed unvalidated history values into metric
    dimensions. Fixed (Decision 90), plus a corrected truncation-order claim and a
    for_each instance error code the card was dropping.

Validation (re-run after the rebase)

  • ruff check src tests — clean
  • mypy src (strict) — clean
  • Targeted: telemetry/status/UI + workflow engine + native retry + task timeout
    167 passed
  • Full unit gate: pytest --cov1203 passed, 89% total coverage
  • mkdocs build --strict — clean
  • Focused UI tests run under Node 24 (not skipped)

Real Functions-host E2E for PR 1 now lives in #185 (bd56857f) and is run
across the stack separately.

Retarget / rebase plan

  1. feat(workflows): add Durable native Activity retry #185 merges to main.
  2. feat(workflows): per-attempt task timeout and continue-on-error #186 is retargeted to main and rebased; its diff reduces to slice 2.
  3. This PR is retargeted to main and rebased onto the merged feat(workflows): per-attempt task timeout and continue-on-error #186.

Every commit here touches only PR-3 files and depends on PR-2 semantics but not
PR-2 commit ids, so the series stays rebaseable and cherry-pickable.

Tsuyoshi Ushio and others added 9 commits August 29, 2026 14:18
Records the third stacked slice of the task execution policy in FRD 0004:
the Activity-owned span, the versioned structured status, and the
compatibility boundary between them.

Decisions 82-89 capture the resolutions from the independent architecture
review: telemetry is emitted per Activity delivery rather than per
orchestrator scheduling decision, the disposition reports what the Activity
did rather than what Durable will decide, counters carry only
low-cardinality dimensions, failure spans are classified explicitly because
terminal failures are returned, the new status version is gated on the
persisted execution policy, a continued node keeps its scheduler state so
the shipped aggregate contract is untouched, the node that ends a workflow
is published before the failure propagates, and per-instance failure detail
is capped deterministically.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds a `workflow.task.activity` span helper plus
`azure_functions_agents.workflow_task.attempts` and
`.outcomes` counters.

The counters take a deliberately narrow dimension set. Workflow ids, node
instance ids, target names, and handler-authored error codes are unbounded,
so a counter keyed on them would create one time series per task attempt;
they stay on the span, where per-attempt cardinality is the point.

`complete()` sets the span's error status explicitly because a workflow
task failure is *returned* rather than raised, so the span would otherwise
close successfully for every terminal failure.

`_ensure_metrics` now creates each instrument in its own guard. A meter
that rejects one instrument previously nulled every counter in the runtime.

`bounded_attribute` collapses control characters in identifier-like
attributes, which `bounded_content` (sized for model content) does not.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Wraps a policy-aware delivery in the `workflow.task.activity` span.

Telemetry belongs in the Activity, not the orchestrator: an orchestrator
re-executes from history on every replay, so a span opened there would be
re-emitted for work that already happened, while this body runs exactly
once per delivery.

The attempt loop is split out of `invoke_policy_handler` into
`_run_policy_attempt`, which classifies every failure and returns it
instead of raising. The caller records the outcome and closes the span
before raising the private Durable retry marker, so a declared retry is not
recorded as an unhandled span exception on every attempt.

`disposition` reports what the Activity did -- `return_result`,
`request_durable_retry`, `return_failure`, `abort` -- never what
Durable will decide, which the Activity cannot see. The attempt number stays
absent for the same reason (Decision 73).

`early_policy_outcome_with_telemetry` covers the deliveries that never
reach a handler, reading attributes defensively because the input behind a
handler_contract outcome is by definition untrusted.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…dler

Authorization denials, unregistered targets, and malformed persisted input
return a policy-aware failure without ever invoking a handler, so they would
otherwise be the only Activity deliveries with no span at all -- exactly the
deliveries an operator most needs to see.

Also passes the Sub Agent target type through to the delivery span so the
two Activities are distinguishable in a query.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A dynamically controlled plan whose persisted input froze at least one
execution policy now publishes schema_version 3. A plan that froze none keeps
emitting version 2 byte-identically, so an older history replays unchanged and
a version 2 client is unaffected.

Version 3 adds retry_driver="durable", the counts pending / failed /
failed_continued, the per-node max_attempts, and the sanitized
last_failure_kind / last_error_code of a continued failure. Attempts in flight
stay absent: Durable owns the budget and a replayed orchestration cannot
observe it, so the status discloses the declared budget and the driver rather
than claiming observability it does not have.

failed_continued is a reporting projection, not a scheduler state.
_aggregate_dynamic_node copies the instance state into the persisted
{index, status, result} aggregate, so a continued instance stays completed and
carries private metadata the status reads instead. That metadata is recorded
where the continuation is decided rather than inferred from the committed
result, because a handler may legitimately return a failed key of its own.

The node whose failure ends a workflow is now marked and published before the
exception propagates, so the last status names it instead of freezing on the
pre-failure snapshot. An opaque Durable failure carries no sanitized cause, so
none is attributed.

Per-instance failure detail is capped in (node id, index) order: MAX_NODES
instances with maximal failure fields would approach Durable's custom-status
size limit, and an order-based cap keeps the status a pure function of
persisted history.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The chat card accepts schema_version 3 and renders its new counts, the frozen
attempt budget, and the stable error code of a continued failure. A continued
instance counts toward its for_each node's progress because it ran to a
committed result.

An unknown version still degrades to compact JSON, so a client that ships
before the next version is never broken by it.

The card also needed an explicit shrink floor and border-box sizing: a
structured status line has no spaces to break on, so it overflowed its column
instead of wrapping.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Asserts the invariants the slice depends on rather than the shape it happens
to emit: telemetry only where the work runs, a status that replays
identically, and a bounded public surface.

- one span per delivery, with the frozen policy and no attempt number;
- a retryable failure recorded before the private Durable marker is raised;
- denied and malformed deliveries recorded without trusting their payload;
- no span at all for a task that froze no policy;
- counters that carry only low-cardinality dimensions;
- version 2 preserved for a plan with no frozen policy;
- a continued failure reported as failed_continued while the ordered
  aggregate still reports completed;
- the node that ended a workflow named in the final status, and none named
  for an opaque Durable failure;
- a maximally expanded plan inside Durable's custom-status size limit, with a
  deterministic failure-detail cap;
- the UI rendering version 3 and still degrading an unknown version.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the workflow.task.activity span, its attribute table, and the two
low-cardinality counters to the Observability section, and documents
custom_status schema version 3 alongside the shapes it does not replace.

Also updates the engine, activity, and observability module map rows plus the
observability extension-point note in docs/architecture.md, and cross-links
the continuation and retry sections to where per-attempt visibility actually
lives.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The early-failure path reads its policy fields from the payload that, for a
handler_contract outcome, is by definition the one that just failed
validation -- and max_attempts and continue_on_error are metric dimensions,
so an out-of-domain value there creates a bogus time series rather than a
bogus span attribute. They are now admitted only inside their validated
domain. Identifiers stay best-effort because they never leave the span.

Also corrects the documented truncation order for per-instance failure detail
(persisted plan order, then instance index -- not sorted node id), and
surfaces a continued instance's error code on a for_each node in the chat
card, where it is only present per instance.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@TsuyoshiUshio
Tsuyoshi Ushio (TsuyoshiUshio) force-pushed the tsuyoshiushio-workflow-execution-telemetry branch from f141216 to 457d39e Compare August 29, 2026 21:20
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.

1 participant