Skip to content

feat: add Workflow Insight instrumentation plugin - #632

Open
wangyb-A wants to merge 6 commits into
mainfrom
feat/workflow-insight-plugin
Open

feat: add Workflow Insight instrumentation plugin#632
wangyb-A wants to merge 6 commits into
mainfrom
feat/workflow-insight-plugin

Conversation

@wangyb-A

Copy link
Copy Markdown
Contributor

Summary

Adds a Workflow Insight instrumentation plugin as a new package,
packages/aws-durable-execution-sdk-python-insight/ — a port of the JS SDK's
workflowInsight() plugin (aws-durable-execution-sdk-js-insight), treated as the
reference implementation throughout. Experimental, matching the JS plugin's status.

It listens to the SDK's instrumentation hooks and emits one curated WorkflowInsight
record (schemaVersion: "1.0") per execution. The wire record keeps the JS camelCase
field names so records read identically across SDKs and land in the same stores/queries.

Behavior (mirrors the JS plugin)

  • Exporters: LambdaLogExporter default (one JSON line to the function's log group,
    carrying the name-keyed operationsByName summary) and S3Exporter (the lossless
    per-occurrence operations array; upsert-by-execution-name; none/date/
    function-name partitioning). boto3 is an extra ([s3]) since Lambda provides it.
  • Emit model: on-complete / on-failure / on-change with export coalescing —
    a newer record supersedes a pending one; exports never propagate errors into the
    execution.
  • Sampling: deterministic per-execution ARN hash; all-or-nothing per execution.
  • Content config: input/output omission or transform (redaction), include_errors
    gating operation-level error detail only, per-operation result opt-in with optional
    transform.
  • Truncation: phase 1 drops opted-in results oldest-first, phase 2 drops whole
    operations oldest-first, input/output last; per-exporter max_record_size_bytes
    measured against the exact shape each exporter emits.
  • Operation detail: top-level (default; children with parentId suppressed) vs
    full-tree; unnamed operations are dropped (JS parity).

Depends on #616 (merged)

The plugin reads InvocationInfo.execution_input / InvocationEndInfo.execution_result
introduced by #616 — the dependency floor is set to >=1.8.0 accordingly (first release
that will carry those hooks). Capability note kept in the module docstring: the operations
map is reconstructed by accumulating per-operation hooks into per-execution state (keyed
by execution ARN to isolate warm-container reuse), since Python hooks carry no
end-of-invocation operations snapshot.

Conformance validation (live, us-west-2)

Validated against the cross-SDK insight conformance suite
(aws/aws-durable-execution-conformance-tests#73, 18 requirements): 18/18 on the s3
sink and 18/18 on the cloudwatch sink
. Two known cross-SDK divergences are documented
in that suite rather than patched over here: operation ids pass through the SDK's native
blake2b[:64] format (JS uses MD5[:16]; the suite asserts ids as opaque), and the
per-operation error.name surfaces the customer error class while the record-level error
carries the SDK wrapper name (the suite asserts non-empty).

The suite's Python example handlers land in the conformance repo as a follow-up to #73
once this package is available.

Testing

  • 17 unit tests (hatch run test:all packages/aws-durable-execution-sdk-python-insight/tests/)
    covering record shaping, operations indexing, truncation phases, sampling, emit modes,
    and exporter rendering
  • hatch fmt clean; package registered in the root known-first-party
  • Live conformance runs as above (JS-parity behavior confirmed record-for-record)

Port of the JS SDK's workflowInsight() plugin as a new package,
aws-durable-execution-sdk-python-insight: listens to the SDK's
instrumentation hooks and emits one curated WorkflowInsight record
(schemaVersion 1.0, JS-identical camelCase wire format) per execution
through configurable exporters (LambdaLogExporter default with the
operationsByName summary; S3Exporter with the per-occurrence operations
array). Mirrors the JS emit model: on-complete/on-failure/on-change
scheduling with coalescing, ARN-hash sampling, content configuration
(input/output omission and transforms, include_errors, per-operation
result opt-in), two-phase truncation, top-level vs full-tree operation
detail, and unnamed-operation dropping. Uses the invocation-hook
execution_input/execution_result fields introduced in #616.
Convert the single exporters.py module into an exporters/ package with one
module per destination (lambda_log_exporter, s3_exporter) plus a private
_common helper, mirroring the JS package's src/exporters/ layout so the set
can grow to full parity (DynamoDB, Firehose, CloudWatch Logs, ...) without a
single file accreting every backend's imports. Public import paths are
unchanged: 'from ...insight import S3Exporter' and
'from ...insight.exporters import S3Exporter' both still resolve. Adds
test_exporters.py covering both exporters (previously untested).
@wangyb-A
wangyb-A deployed to ai-pr-review August 25, 2026 22:47 — with GitHub Actions Active
@wangyb-A
wangyb-A had a problem deploying to ai-pr-review-runtime August 25, 2026 22:52 — with GitHub Actions Error
@wangyb-A
wangyb-A temporarily deployed to ai-pr-review-runtime August 25, 2026 22:52 — with GitHub Actions Inactive
Comment thread packages/aws-durable-execution-sdk-python-insight/pyproject.toml
Comment thread pyproject.toml
@github-actions

This comment has been minimized.

Alex Wang added 2 commits August 26, 2026 22:44
- Seed operation map from InvocationStart/End/OperationChange snapshots
  instead of reconstructing via per-operation hooks (cold-resume correctness)
- on-change mode emits an updated RUNNING record on each change
- Drop on_operation_end/_current_execution_arn heuristic; key strictly by
  execution_arn to prevent cross-execution contamination
- Clear per-execution state after every invocation end (bounded, no leak on
  suspend/retry/sampled-out)
- Default to LambdaLogExporter when exporters omitted or empty
- Always adopt authoritative execution_start_time on resume
- Correct hook enum imports (InvocationStatus/OperationType from plugin)
- Register insight tests in root testpaths and mypy type-checks
@wangyb-A
wangyb-A deployed to ai-pr-review August 26, 2026 23:14 — with GitHub Actions Active
@wangyb-A
wangyb-A temporarily deployed to ai-pr-review-runtime August 26, 2026 23:21 — with GitHub Actions Inactive
@wangyb-A
wangyb-A had a problem deploying to ai-pr-review-runtime August 26, 2026 23:21 — with GitHub Actions Error
Comment thread packages/aws-durable-execution-sdk-python-insight/pyproject.toml
Comment thread packages/aws-durable-execution-sdk-python-insight/README.md
@github-actions

This comment has been minimized.

- 1: wire aws-durable-execution-sdk-python-insight into both the build and
  publish matrices of pypi-publish.yml; the generic legal-file verifier runs
  through the build matrix unchanged (LICENSE+NOTICE confirmed in whl+sdist).
- 3: in on-change mode, a PENDING/RETRY invocation end maps to RUNNING and now
  omits endTime/durationMs; only terminal SUCCEEDED/FAILED records carry an end
  time (plus output/error).
- 4: fix the README usage example to import WorkflowInsightConfig and call
  workflow_insight(WorkflowInsightConfig(exporters=[...])); add a smoke test for
  the documented call shape.
- 5: back EmitMode/OperationDetail with StrEnum (JS-style values); config fields
  use Literal input typing and __post_init__ normalizes accepted strings to enum
  members (invalid dynamic strings raise ValueError); export the enums.
- 6: add a checked-in tests/e2e local-runner integration test that drives the
  real durable_execution/PluginExecutor lifecycle through a suspend/resume wait
  and asserts the terminal record includes the prior step and completed wait.

Comment 2 (asynchronous export scheduling) is intentionally deferred; no async
queue/worker/coalescing/drain was added.
@wangyb-A
wangyb-A deployed to ai-pr-review August 27, 2026 19:47 — with GitHub Actions Active
@github-actions

This comment has been minimized.

@wangyb-A
wangyb-A had a problem deploying to ai-pr-review-runtime August 27, 2026 20:53 — with GitHub Actions Error
@wangyb-A
wangyb-A had a problem deploying to ai-pr-review-runtime August 27, 2026 21:42 — with GitHub Actions Error
wangyb-A pushed a commit that referenced this pull request Aug 27, 2026
Address the S3 partition-validation review comment on PR #632. Add a
public S3Partitioning(StrEnum) (DATE=date, FUNCTION_NAME=function-name,
NONE=none) in the s3_exporter module. The constructor is typed as an
S3Partitioning | Literal[...] union (never bare str) and normalizes input
with S3Partitioning(partitioning), so an invalid dynamic value (e.g.
function_name) raises ValueError at construction instead of silently
falling through to no partitioning. Key building now compares enum members.
Re-export S3Partitioning from the exporters package and top-level package
alongside S3Exporter. Existing API-compatible string inputs are preserved.

Scheduler/flush/queueing/draining behavior is intentionally unchanged.
Address the S3 partition-validation review comment on PR #632. Add a
public S3Partitioning(StrEnum) (DATE=date, FUNCTION_NAME=function-name,
NONE=none) in the s3_exporter module. The constructor is typed as an
S3Partitioning | Literal[...] union (never bare str) and normalizes input
with S3Partitioning(partitioning), so an invalid dynamic value (e.g.
function_name) raises ValueError at construction instead of silently
falling through to no partitioning. Key building now compares enum members.
Re-export S3Partitioning from the exporters package and top-level package
alongside S3Exporter. Existing API-compatible string inputs are preserved.

Scheduler/flush/queueing/draining behavior is intentionally unchanged.
@wangyb-A
wangyb-A force-pushed the feat/workflow-insight-plugin branch from 63f85cd to 953e66f Compare August 27, 2026 22:43
@wangyb-A
wangyb-A deployed to ai-pr-review August 27, 2026 22:43 — with GitHub Actions Active
@wangyb-A
wangyb-A temporarily deployed to ai-pr-review-runtime August 27, 2026 22:55 — with GitHub Actions Inactive
@wangyb-A
wangyb-A had a problem deploying to ai-pr-review-runtime August 27, 2026 22:55 — with GitHub Actions Error
Comment on lines +268 to +278
# on-change mode exports an updated RUNNING record on each change so
# mid-invocation progress is observable, not only at start/end.
if self._emit_mode == EmitMode.ON_CHANGE:
self._emit(
arn,
state,
status="RUNNING",
end_time=None,
output_raw=None,
error=None,
)

This comment was marked as outdated.

@github-actions

This comment has been minimized.

@wangyb-A
wangyb-A marked this pull request as ready for review August 27, 2026 23:34
@wangyb-A
wangyb-A temporarily deployed to ai-pr-review-runtime August 27, 2026 23:34 — with GitHub Actions Inactive
@wangyb-A

Copy link
Copy Markdown
Contributor Author

Note to reviewer: I am tracking the AI reviewer's comments in an issue: #687
And will have following PRs to fix them.
Will not include the full change in this PR, for easier review

@wangyb-A wangyb-A assigned wangyb-A and unassigned wangyb-A Aug 27, 2026
@wangyb-A wangyb-A moved this from Backlog to In review in aws-durable-execution Aug 27, 2026
@wangyb-A
wangyb-A temporarily deployed to ai-pr-review-runtime August 27, 2026 23:41 — with GitHub Actions Inactive
@wangyb-A
wangyb-A had a problem deploying to ai-pr-review-runtime August 27, 2026 23:41 — with GitHub Actions Error

[tool.hatch.build.targets.sdist.force-include]
"../../LICENSE" = "LICENSE"
"../../NOTICE" = "NOTICE"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

add a plugin entry point here to allow this plugin to be auto loaded. See otel plugin for reference

Comment on lines +430 to +435
for exporter in self._exporters:
try:
shaped = truncate_record(
record, exporter.max_record_size_bytes, exporter.render
)
exporter.export(shaped)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Codex AI review

Exporter I/O runs synchronously on the checkpoint critical path. on_operation_change reaches this loop from the checkpoint thread before synchronous checkpoint waiters are released, so every S3 request and retry delays the durable operation; a slow exporter can cause a Lambda timeout despite exceptions being caught. Dispatch exports through a per-execution worker, coalesce pending records to the newest state, and bounded-flush the terminal record. Add a test using a blocking exporter.

Comment on lines +410 to +425
parsed_output: Any = None
if output_raw is not None and output_raw != "":
try:
parsed_output = json.loads(output_raw)
except (json.JSONDecodeError, TypeError):
parsed_output = output_raw
input_value = _apply_data_content(
state.cached_input, content.input if content else None
)
output_value = _apply_data_content(
parsed_output, content.output if content else None
)
if input_value is not None:
record["input"] = input_value
if output_value is not None:
record["output"] = output_value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Codex AI review

Valid output states are conflated with omission. A handler returning None serializes as "null", is parsed back to None, and is then omitted. Null input and identity-transformed null operation results are similarly lost. Also, the core hook uses "" for an out-of-band large result, which is silently omitted without truncated/droppedOutput. Use an explicit missing-value sentinel so JSON null is retained, mark unavailable large output as dropped, and apply the same presence-based checks to operation results and operationsByName. Add null and large-output tests.

@github-actions

Copy link
Copy Markdown
Contributor

Codex AI review

Two findings affect execution isolation and record fidelity. Static review only, as required.

Reviewed commit 953e66f62914d9d61c4f165c743533ff2cec4464. Workflow run

@ParidelPooya ParidelPooya 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.

Reviewed locally at 953e66f. Verified: 60 tests pass (~1s), hatch run types:check clean, hatch fmt --check clean for this package, wheel/sdist build with LICENSE+NOTICE correctly placed.

Solid, careful port. truncation.py and operations_index.py are near line-for-line faithful to the JS reference, the ARN parsing and FNV-1a math match, and sourcing operations from the SDK's authoritative operations snapshots (rather than accumulating on_operation_end events) is the right call — the e2e suspend/resume test proves it survives a cold resume. The comments are unusually good at explaining why.

Concerns are concentrated in one behavioral gap plus a few correctness edges and test holes. Three I'd want addressed before merge:

  1. flush() is declared on the exporter protocol but never called anywhere.
  2. There is no export scheduler / coalescing, contrary to the PR description, and exports run inline on the checkpoint thread in on-change mode.
  3. sampling_rate=NaN silently disables all instrumentation (JS fails open to 1.0 with a warning).

Details inline.

record["error"] = {"name": error.type, "message": error.message}
record["operations"] = self._build_operations(operations)

for exporter in self._exporters:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

flush() is never called.

InsightExporter declares flush(), both shipped exporters implement it, and the JS plugin calls flushAll(exporters) in wrapInvocation. Nothing in this package ever invokes it — grep -rn flush src/ returns only definitions.

Both first-party exporters are unbuffered, so this is latent today, but the protocol advertises a lifecycle contract the plugin doesn't honor: any customer exporter that batches will silently drop records. The Python SDK has no wrap_invocation hook, so the natural place is right after the terminal export here (or at the end of on_invocation_end).

Alternatively, if flushing is deliberately out of scope, make flush() optional in the protocol rather than required.

error=None,
)

def on_operation_change(self, info: OperationChangeInfo) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No export scheduler — and the PR description claims one.

The description says "emit model … with export coalescing — a newer record supersedes a pending one." There is no ExportScheduler equivalent here; _emit calls exporter.export() inline. In on-change mode that has two consequences:

  1. OperationChangeInfo is dispatched by the SDK with sync=True (PluginExecutor.execute_plugins, plugin.py:823), i.e. inline on the checkpoint-processing thread. A blocking put_object here stalls the SDK's checkpoint pump for every S3 round trip.
  2. Every operation status change becomes one PutObject to the same key, with no coalescing. A 200-operation execution issues ~200 writes where JS would issue far fewer.

I checked for an out-of-order/overwrite hazard and there isn't one: on_invocation_start runs before the checkpoint thread starts, changes are serialized on the single checkpoint thread, and on_invocation_end fires from handle_durable_output after the ThreadPoolExecutor(max_workers=2) block has joined both workers. So the terminal record is always written last. The issues are latency and write amplification, not correctness.

Either implement coalescing or correct the description — right now the two disagree.

Separately: this hook calls _ensure_state and _adopt_operations unconditionally, whereas JS returns early unless emitMode === "on-change". In the default on-complete mode that work is pure waste, since on_invocation_end re-adopts a fresh snapshot anyway. Cheap (shallow copy, small next to the SDK's own eager _to_operation_info_map), but an early return is strictly better.

return _fnv1a32(execution_arn) / 0xFFFFFFFF < rate


def _resolve_sampling_rate(rate: float | None) -> float:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

sampling_rate=NaN silently disables all instrumentation.

NaN falls through every guard here (nan < 0 and nan > 1 are both False), and then _should_sample returns False for every ARN. Verified:

rate_in=nan       resolved=nan   sampled_in=False
rate_in='0.5'     resolved=1.0   sampled_in=True

JS handles this explicitly — Number.isNaN(rate)1.0 plus a console.warn. Fail-open on misconfiguration is the safer default, and the current fail-closed behavior is completely invisible: no records, no warning.

Related: the string case silently coerces to 1.0 with no warning either. That sits oddly next to EmitMode / OperationDetail / S3Partitioning, which all raise ValueError on a bad dynamic value. Worth picking one philosophy for the config surface.

return None


def _apply_data_content(value: Any, setting: Any) -> Any:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A null execution output is dropped.

The early return on value is None collapses JS's undefined-vs-null distinction. A handler returning None yields execution_result == "null"json.loadsNoneoutput omitted from the record, where JS emits output: null.

Cosmetic, but it is a wire divergence in exactly the schema the cross-SDK conformance suite compares.


# -- sampling / state -----------------------------------------------------

def _sampled_in(self, execution_arn: str) -> bool:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sampling is recomputed on every hook — this re-hashes the ~120-char ARN each time. JS computes it once and caches it in ExecutionState. Harmless, but the cache is free and the state object already exists.

record, exporter.max_record_size_bytes, exporter.render
)
exporter.export(shaped)
except Exception as exc: # noqa: BLE001 - one exporter must not break others / the execution

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A library should use logging rather than print to stderr; the SDK already has a module logger and customers can't filter or route this.

Also worth knowing: the SDK's _dispatch_plugin already catches and logs exceptions escaping any hook, so this broad except is belt-and-braces rather than the only line of defense.

# YYYY-MM-DD... -> year=YYYY/month=MM/day=DD/
if len(start) >= 10 and start[4] == "-" and start[7] == "-":
return f"year={start[0:4]}/month={start[5:7]}/day={start[8:10]}/"
return ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When startTime is absent or malformed, date partitioning silently degrades to no partition and the object lands at the prefix root, mixing unpartitioned objects into an otherwise partitioned layout — awkward for Athena partition projection.

This is better than JS, which produces year=NaN/month=NaN/day=NaN/, but a year=unknown/ sentinel would keep the layout uniform.

assert exporter.records == []


def test_sampling_zero_emits_nothing():

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sampling's deterministic hash is never executed. Coverage shows plugin.py:90 (_fnv1a32 body) unhit — this is the only sampling test and rate 0 short-circuits before the hash. That leaves the one piece that must agree with JS record-for-record completely untested.

Worth adding:

  • a vector test (_fnv1a32(known_arn) == <value computed by the JS implementation>) to lock in cross-SDK parity;
  • a fractional-rate test asserting the same ARN yields the same decision across repeated calls / a fresh plugin instance;
  • the clamp and non-numeric paths in _resolve_sampling_rate (plugin.py:109, 111).

Three other gaps in this file, all cheap:

  • OperationOverride.exclude is untested (plugin.py:339 unhit) — a documented config knob with zero coverage.
  • Per-operation error inclusion is untested (plugin.py:360 unhit); only the include_errors=False path is covered.
  • Exporter-failure isolation is untested (plugin.py:436 unhit). "One exporter must not break others / the execution" is a core safety claim and a throwing-exporter test is a few lines.

assert "bulk-3" in names # newest retained


def test_truncation_noop_when_within_limit():

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Truncation phase 3 is untested. truncation.py:100–105 and the pop lines 68/71 are unhit, so droppedInput / droppedOutput — documented in both the README and the PR description — are never exercised. Phases 1 and 2 are covered; a case where dropping every operation still leaves the record over the limit would close this out.

packages/aws-durable-execution-sdk-python-otel/tests

mypy --install-types --non-interactive \
packages/aws-durable-execution-sdk-python-insight/src/aws_durable_execution_sdk_python_insight \

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two CI-wiring omissions while you're in here:

  1. ci.yml's "Verify legal files in published distributions" step takes an explicit package list (core / otel / testing) and doesn't include insight, even though this package declares force-include for LICENSE and NOTICE in both sdist and wheel targets. I ran check_dist_legal_files.py against the built package manually and it passes — just add it to the list so the contract stays enforced.
  2. The repo convention of a per-package [tool.hatch.envs.dev-*] env plus an entry in .github/scripts/ci-checks.sh (present for core, otel, testing, examples) wasn't followed, so the local dev script skips insight entirely.

ci.yml's fmt and build steps loop over packages/*/, so those are already covered.

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

Labels

None yet

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

3 participants