diff --git a/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java b/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java
new file mode 100644
index 000000000..89d4ad3fe
--- /dev/null
+++ b/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java
@@ -0,0 +1,198 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package plugin;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Locale;
+import software.amazon.lambda.durable.DurableConfig;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.config.StepConfig;
+import software.amazon.lambda.durable.plugin.DurableExecutionPlugin;
+import software.amazon.lambda.durable.plugin.InvocationInfo;
+import software.amazon.lambda.durable.plugin.UserFunctionEndInfo;
+import software.amazon.lambda.durable.plugin.UserFunctionStartInfo;
+import software.amazon.lambda.durable.retry.JitterStrategy;
+import software.amazon.lambda.durable.retry.RetryStrategies;
+
+/**
+ * 10-21: Attempt hook info field shape (CANONICAL DUMP).
+ *
+ *
A single step named {@code "flaky"} that throws on attempt 1 and succeeds on attempt 2 using the SDK's real
+ * exponential-backoff retry strategy (max attempts 3, ~1s delay), returning {@code "ok"}. The instrumentation plugin
+ * (filtering to step-type attempts) emits ONE single-line JSON record per per-attempt (user-function) hook event: a
+ * canonical dump of that hook's OWN info parameter, every exposed component mapped to its canonical camelCase name,
+ * null / unexposed fields OMITTED.
+ *
+ *
Java's {@link UserFunctionStartInfo} exposes id/name/type/subType/parentId/startTimestamp/isReplay/
+ * isReplayingChildren/attempt (no endTimestamp/outcome/error at start). Java's {@link UserFunctionEndInfo} adds
+ * endTimestamp, the {@code succeeded} boolean (presented as the shared {@code outcome} SUCCEEDED/FAILED token) and
+ * {@code error}. {@code isReplay} is the operation-level replay indicator (this operation was present in the
+ * checkpointed state delivered at invocation start); {@code isReplayingChildren} is the distinct context-children
+ * indicator and is dumped unasserted here.
+ */
+@SuppressWarnings("deprecation")
+public class PluginAttemptInfoShape extends DurableHandler {
+
+ @Override
+ protected DurableConfig createConfiguration() {
+ return DurableConfig.builder().withPlugins(new AttemptShapePlugin()).build();
+ }
+
+ @Override
+ public String handleRequest(Object input, DurableContext context) {
+ return context.step(
+ "flaky",
+ String.class,
+ stepCtx -> {
+ // Fail on the first attempt, succeed on the second, using the SDK's built-in 1-based attempt
+ // number.
+ if (stepCtx.getAttempt() < 2) {
+ throw new RuntimeException("Attempt " + stepCtx.getAttempt() + " failed");
+ }
+ return "ok";
+ },
+ StepConfig.builder()
+ .retryStrategy(RetryStrategies.exponentialBackoff(
+ 3, Duration.ofSeconds(1), Duration.ofSeconds(10), 1.0, JitterStrategy.NONE))
+ .build());
+ }
+
+ private static final class AttemptShapePlugin implements DurableExecutionPlugin {
+ private volatile String executionArn;
+
+ @Override
+ public void onInvocationStart(InvocationInfo info) {
+ this.executionArn = info.durableExecutionArn();
+ }
+
+ @Override
+ public void onUserFunctionStart(UserFunctionStartInfo info) {
+ if (!PluginSupport.isStep(info.type()) || info.attempt() == null) {
+ return;
+ }
+ new Rec("attempt-start")
+ .str("id", info.id())
+ .str("name", info.name())
+ .str("type", Rec.upper(info.type()))
+ .str("subType", info.subType())
+ .str("parentId", info.parentId())
+ .num("attempt", info.attempt())
+ .time("startTimestamp", info.startTimestamp())
+ .bool("isReplay", info.isReplay())
+ .bool("isReplayingChildren", info.isReplayingChildren())
+ .emit(executionArn);
+ }
+
+ @Override
+ public void onUserFunctionEnd(UserFunctionEndInfo info) {
+ if (!PluginSupport.isStep(info.type()) || info.attempt() == null) {
+ return;
+ }
+ new Rec("attempt-end")
+ .str("id", info.id())
+ .str("name", info.name())
+ .str("type", Rec.upper(info.type()))
+ .str("subType", info.subType())
+ .str("parentId", info.parentId())
+ .num("attempt", info.attempt())
+ .time("startTimestamp", info.startTimestamp())
+ .time("endTimestamp", info.endTimestamp())
+ .bool("isReplay", info.isReplay())
+ .bool("isReplayingChildren", info.isReplayingChildren())
+ .str("outcome", info.succeeded() ? "SUCCEEDED" : "FAILED")
+ .str("error", Rec.msg(info.error()))
+ .emit(executionArn);
+ }
+ }
+
+ /** Single-line JSON record builder: emits every provided key, skipping nulls, then stamps durableExecutionArn. */
+ private static final class Rec {
+ private final StringBuilder sb = new StringBuilder("{");
+
+ Rec(String hook) {
+ raw("plugin", "\"CONFPLUGIN\"");
+ raw("hook", "\"" + hook + "\"");
+ }
+
+ Rec str(String key, String value) {
+ if (value != null) {
+ raw(key, quote(value));
+ }
+ return this;
+ }
+
+ Rec num(String key, Integer value) {
+ if (value != null) {
+ raw(key, value.toString());
+ }
+ return this;
+ }
+
+ Rec bool(String key, boolean value) {
+ raw(key, value ? "true" : "false");
+ return this;
+ }
+
+ Rec time(String key, Instant value) {
+ if (value != null) {
+ raw(key, quote(value.toString()));
+ }
+ return this;
+ }
+
+ private void raw(String key, String jsonValue) {
+ if (sb.length() > 1) {
+ sb.append(", ");
+ }
+ sb.append('"').append(key).append("\": ").append(jsonValue);
+ }
+
+ void emit(String executionArn) {
+ System.out.println(sb.append(PluginSupport.arnField(executionArn)).append('}'));
+ }
+
+ static String upper(String s) {
+ return s == null ? null : s.toUpperCase(Locale.ROOT);
+ }
+
+ static String msg(Throwable t) {
+ if (t == null) {
+ return null;
+ }
+ return t.getMessage() != null ? t.getMessage() : t.toString();
+ }
+
+ static String quote(String s) {
+ StringBuilder b = new StringBuilder("\"");
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"':
+ b.append("\\\"");
+ break;
+ case '\\':
+ b.append("\\\\");
+ break;
+ case '\n':
+ b.append("\\n");
+ break;
+ case '\r':
+ b.append("\\r");
+ break;
+ case '\t':
+ b.append("\\t");
+ break;
+ default:
+ if (c < 0x20) {
+ b.append(String.format("\\u%04x", (int) c));
+ } else {
+ b.append(c);
+ }
+ }
+ }
+ return b.append('"').toString();
+ }
+ }
+}
diff --git a/conformance-tests/src/main/java/plugin/PluginContextInfoShape.java b/conformance-tests/src/main/java/plugin/PluginContextInfoShape.java
new file mode 100644
index 000000000..966c3d934
--- /dev/null
+++ b/conformance-tests/src/main/java/plugin/PluginContextInfoShape.java
@@ -0,0 +1,187 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package plugin;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import software.amazon.lambda.durable.DurableConfig;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableFuture;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.ParallelDurableFuture;
+import software.amazon.lambda.durable.config.ParallelConfig;
+import software.amazon.lambda.durable.plugin.DurableExecutionPlugin;
+import software.amazon.lambda.durable.plugin.InvocationInfo;
+import software.amazon.lambda.durable.plugin.OperationInfo;
+import software.amazon.lambda.durable.plugin.UserFunctionStartInfo;
+
+/**
+ * 10-23: Context-typed hook info field shape (CANONICAL DUMP).
+ *
+ * A parallel operation named {@code "ctx"} with max-concurrency 1 and two branches: branch A runs a step named
+ * {@code "inner"} returning {@code "x"}, then a 2-second wait, and returns {@code "a-done"}; branch B returns
+ * {@code "b-done"} directly. With max-concurrency 1 branch A runs live, suspends on the wait, and re-runs on the replay
+ * (replaying its checkpointed children) before branch B runs live — so the children-replay indicator flips true on
+ * branch A's second {@code fn-start}.
+ *
+ *
The instrumentation plugin (filtering to CONTEXT-type operations) emits ONE single-line JSON record per hook
+ * event: a canonical camelCase dump of that hook's OWN info parameter, null / unset fields OMITTED.
+ *
+ *
Java's {@link OperationInfo} (operation-start) exposes id/name/type/subType/parentId/startTimestamp/endTimestamp/
+ * status/isReplay. Java's {@link UserFunctionStartInfo} (fn-start) exposes id/name/type/subType/parentId/
+ * startTimestamp/isReplayingChildren/attempt — for CONTEXT operations {@code attempt} is null and is omitted. Only
+ * fn-start is probed; attempt-end hooks are out of scope for a suspending context run.
+ */
+@SuppressWarnings("deprecation")
+public class PluginContextInfoShape extends DurableHandler> {
+
+ @Override
+ protected DurableConfig createConfiguration() {
+ return DurableConfig.builder().withPlugins(new ContextShapePlugin()).build();
+ }
+
+ @Override
+ public List handleRequest(Object input, DurableContext context) {
+ var config = ParallelConfig.builder().maxConcurrency(1).build();
+ var futures = new ArrayList>();
+ ParallelDurableFuture parallel = context.parallel("ctx", config);
+ try (parallel) {
+ futures.add(parallel.branch("branch-a", String.class, branch -> {
+ branch.step("inner", String.class, stepCtx -> "x");
+ branch.wait(null, Duration.ofSeconds(2));
+ return "a-done";
+ }));
+ futures.add(parallel.branch("branch-b", String.class, branch -> "b-done"));
+ }
+ return futures.stream().map(DurableFuture::get).toList();
+ }
+
+ private static final class ContextShapePlugin implements DurableExecutionPlugin {
+ private volatile String executionArn;
+
+ @Override
+ public void onInvocationStart(InvocationInfo info) {
+ this.executionArn = info.durableExecutionArn();
+ }
+
+ @Override
+ public void onOperationStart(OperationInfo info) {
+ if (!PluginSupport.isContext(info.type())) {
+ return;
+ }
+ new Rec("operation-start")
+ .str("id", info.id())
+ .str("name", info.name())
+ .str("type", Rec.upper(info.type()))
+ .str("subType", info.subType())
+ .str("parentId", info.parentId())
+ .str("status", Rec.upper(info.status()))
+ .time("startTimestamp", info.startTimestamp())
+ .time("endTimestamp", info.endTimestamp())
+ .bool("isReplay", info.isReplay())
+ .emit(executionArn);
+ }
+
+ @Override
+ public void onUserFunctionStart(UserFunctionStartInfo info) {
+ if (!PluginSupport.isContext(info.type())) {
+ return;
+ }
+ new Rec("fn-start")
+ .str("id", info.id())
+ .str("name", info.name())
+ .str("type", Rec.upper(info.type()))
+ .str("subType", info.subType())
+ .str("parentId", info.parentId())
+ .num("attempt", info.attempt())
+ .time("startTimestamp", info.startTimestamp())
+ .bool("isReplayingChildren", info.isReplayingChildren())
+ .emit(executionArn);
+ }
+ }
+
+ /** Single-line JSON record builder: emits every provided key, skipping nulls, then stamps durableExecutionArn. */
+ private static final class Rec {
+ private final StringBuilder sb = new StringBuilder("{");
+
+ Rec(String hook) {
+ raw("plugin", "\"CONFPLUGIN\"");
+ raw("hook", "\"" + hook + "\"");
+ }
+
+ Rec str(String key, String value) {
+ if (value != null) {
+ raw(key, quote(value));
+ }
+ return this;
+ }
+
+ Rec num(String key, Integer value) {
+ if (value != null) {
+ raw(key, value.toString());
+ }
+ return this;
+ }
+
+ Rec bool(String key, boolean value) {
+ raw(key, value ? "true" : "false");
+ return this;
+ }
+
+ Rec time(String key, Instant value) {
+ if (value != null) {
+ raw(key, quote(value.toString()));
+ }
+ return this;
+ }
+
+ private void raw(String key, String jsonValue) {
+ if (sb.length() > 1) {
+ sb.append(", ");
+ }
+ sb.append('"').append(key).append("\": ").append(jsonValue);
+ }
+
+ void emit(String executionArn) {
+ System.out.println(sb.append(PluginSupport.arnField(executionArn)).append('}'));
+ }
+
+ static String upper(String s) {
+ return s == null ? null : s.toUpperCase(Locale.ROOT);
+ }
+
+ static String quote(String s) {
+ StringBuilder b = new StringBuilder("\"");
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"':
+ b.append("\\\"");
+ break;
+ case '\\':
+ b.append("\\\\");
+ break;
+ case '\n':
+ b.append("\\n");
+ break;
+ case '\r':
+ b.append("\\r");
+ break;
+ case '\t':
+ b.append("\\t");
+ break;
+ default:
+ if (c < 0x20) {
+ b.append(String.format("\\u%04x", (int) c));
+ } else {
+ b.append(c);
+ }
+ }
+ }
+ return b.append('"').toString();
+ }
+ }
+}
diff --git a/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java b/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java
new file mode 100644
index 000000000..89b41b80e
--- /dev/null
+++ b/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java
@@ -0,0 +1,161 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package plugin;
+
+import java.time.Duration;
+import java.time.Instant;
+import software.amazon.lambda.durable.DurableConfig;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.plugin.DurableExecutionPlugin;
+import software.amazon.lambda.durable.plugin.InvocationEndInfo;
+import software.amazon.lambda.durable.plugin.InvocationInfo;
+import software.amazon.lambda.durable.plugin.InvocationStatus;
+
+/**
+ * 10-19: Invocation hook info field shape (CANONICAL DUMP).
+ *
+ * A single 2-second wait that then returns {@code "done-" + input}. The instrumentation plugin emits ONE single-line
+ * JSON record per invocation hook event: a canonical dump of that hook's OWN info parameter, every exposed component
+ * mapped one-to-one to its canonical camelCase name, null / unexposed fields OMITTED (a missing key fails its assertion
+ * — the parity signal).
+ *
+ *
Java's {@link InvocationInfo} exposes {@code requestId}, {@code executionStartTime} (→
+ * {@code executionStartTimestamp}), {@code isFirstInvocation} and the {@code operations} / {@code updatedOperations}
+ * maps (dumped as {@code operationsCount} / {@code updatedOperationsCount}); it does NOT expose the execution input, so
+ * {@code executionInput} is absent. Java's {@link InvocationEndInfo} carries the same identity surface plus
+ * {@code invocationStatus} (→ {@code status}) and {@code executionError}; it does NOT expose the execution input or the
+ * final result, so {@code executionInput} and {@code executionResult} are absent. The single derived scalar
+ * {@code terminal} := status in (SUCCEEDED, FAILED). Those payload omissions are deliberate — payload surfaces are out
+ * of GA scope.
+ */
+@SuppressWarnings("deprecation")
+public class PluginInvocationInfoShape extends DurableHandler {
+
+ @Override
+ protected DurableConfig createConfiguration() {
+ return DurableConfig.builder().withPlugins(new InvocationShapePlugin()).build();
+ }
+
+ @Override
+ public String handleRequest(String input, DurableContext context) {
+ context.wait(null, Duration.ofSeconds(2));
+ return "done-" + input;
+ }
+
+ private static final class InvocationShapePlugin implements DurableExecutionPlugin {
+ private volatile String executionArn;
+
+ @Override
+ public void onInvocationStart(InvocationInfo info) {
+ this.executionArn = info.durableExecutionArn();
+ new Rec("invocation-start")
+ .bool("isFirstInvocation", info.isFirstInvocation())
+ .str("requestId", info.requestId())
+ .num("operationsCount", info.operations().size())
+ .num("updatedOperationsCount", info.updatedOperations().size())
+ .time("executionStartTimestamp", info.executionStartTime())
+ .emit(executionArn);
+ }
+
+ @Override
+ public void onInvocationEnd(InvocationEndInfo info) {
+ InvocationStatus status = info.invocationStatus();
+ boolean terminal = status == InvocationStatus.SUCCEEDED || status == InvocationStatus.FAILED;
+ new Rec("invocation-end")
+ .bool("isFirstInvocation", info.isFirstInvocation())
+ .str("requestId", info.requestId())
+ .num("operationsCount", info.operations().size())
+ .time("executionStartTimestamp", info.executionStartTime())
+ .str("status", status == null ? null : status.name())
+ .bool("terminal", terminal)
+ .str("executionError", Rec.msg(info.executionError()))
+ .emit(executionArn);
+ }
+ }
+
+ /** Single-line JSON record builder: emits every provided key, skipping nulls, then stamps durableExecutionArn. */
+ private static final class Rec {
+ private final StringBuilder sb = new StringBuilder("{");
+
+ Rec(String hook) {
+ raw("plugin", "\"CONFPLUGIN\"");
+ raw("hook", "\"" + hook + "\"");
+ }
+
+ Rec str(String key, String value) {
+ if (value != null) {
+ raw(key, quote(value));
+ }
+ return this;
+ }
+
+ Rec num(String key, Integer value) {
+ if (value != null) {
+ raw(key, value.toString());
+ }
+ return this;
+ }
+
+ Rec bool(String key, boolean value) {
+ raw(key, value ? "true" : "false");
+ return this;
+ }
+
+ Rec time(String key, Instant value) {
+ if (value != null) {
+ raw(key, quote(value.toString()));
+ }
+ return this;
+ }
+
+ private void raw(String key, String jsonValue) {
+ if (sb.length() > 1) {
+ sb.append(", ");
+ }
+ sb.append('"').append(key).append("\": ").append(jsonValue);
+ }
+
+ void emit(String executionArn) {
+ System.out.println(sb.append(PluginSupport.arnField(executionArn)).append('}'));
+ }
+
+ static String msg(Throwable t) {
+ if (t == null) {
+ return null;
+ }
+ return t.getMessage() != null ? t.getMessage() : t.toString();
+ }
+
+ static String quote(String s) {
+ StringBuilder b = new StringBuilder("\"");
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"':
+ b.append("\\\"");
+ break;
+ case '\\':
+ b.append("\\\\");
+ break;
+ case '\n':
+ b.append("\\n");
+ break;
+ case '\r':
+ b.append("\\r");
+ break;
+ case '\t':
+ b.append("\\t");
+ break;
+ default:
+ if (c < 0x20) {
+ b.append(String.format("\\u%04x", (int) c));
+ } else {
+ b.append(c);
+ }
+ }
+ }
+ return b.append('"').toString();
+ }
+ }
+}
diff --git a/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java b/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java
new file mode 100644
index 000000000..b4915114b
--- /dev/null
+++ b/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java
@@ -0,0 +1,170 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package plugin;
+
+import java.time.Instant;
+import java.util.Locale;
+import software.amazon.lambda.durable.DurableConfig;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.plugin.DurableExecutionPlugin;
+import software.amazon.lambda.durable.plugin.InvocationInfo;
+import software.amazon.lambda.durable.plugin.OperationChangeInfo;
+import software.amazon.lambda.durable.plugin.OperationChangeItemInfo;
+
+/**
+ * 10-22: Operation-change hook info field shape (CANONICAL DUMP).
+ *
+ * A single step named {@code "greet"} returning the constant {@code "task-a"}. For each step-type operation in the
+ * change info's updated-operations delta the instrumentation plugin emits ONE single-line JSON record: a canonical dump
+ * of that DELTA ITEM's OWN field surface, plus the hook-level fields {@code executionArn} (from the change info),
+ * {@code updatedOperationsCount}/{@code operationsCount} (map sizes) and the derived {@code inFullMap} := the same id
+ * also appears in the info's full operations map. Null / unexposed fields are OMITTED.
+ *
+ *
Java's {@link OperationChangeItemInfo} exposes the full operation field surface — id/name/type/subType/parentId/
+ * startTimestamp/endTimestamp/status/attempt/isReplay/error. It does NOT expose the checkpointed serialized result, so
+ * {@code result} is absent; payload surfaces are deliberately out of GA scope.
+ */
+@SuppressWarnings("deprecation")
+public class PluginOperationChangeShape extends DurableHandler {
+
+ @Override
+ protected DurableConfig createConfiguration() {
+ return DurableConfig.builder().withPlugins(new ChangeShapePlugin()).build();
+ }
+
+ @Override
+ public String handleRequest(Object input, DurableContext context) {
+ return context.step("greet", String.class, stepCtx -> "task-a");
+ }
+
+ private static final class ChangeShapePlugin implements DurableExecutionPlugin {
+ private volatile String executionArn;
+
+ @Override
+ public void onInvocationStart(InvocationInfo info) {
+ this.executionArn = info.durableExecutionArn();
+ }
+
+ @Override
+ public void onOperationChange(OperationChangeInfo info) {
+ int updatedOperationsCount = info.updatedOperations().size();
+ int operationsCount = info.operations().size();
+ for (OperationChangeItemInfo item : info.updatedOperations().values()) {
+ if (!PluginSupport.isStepChange(item.type())) {
+ continue;
+ }
+ new Rec("operation-change")
+ .str("executionArn", info.durableExecutionArn())
+ .num("updatedOperationsCount", updatedOperationsCount)
+ .num("operationsCount", operationsCount)
+ .bool("inFullMap", info.operations().containsKey(item.id()))
+ .str("id", item.id())
+ .str("name", item.name())
+ .str("type", Rec.upper(item.type()))
+ .str("subType", item.subType())
+ .str("parentId", item.parentId())
+ .str(
+ "status",
+ item.status() == null
+ ? null
+ : Rec.upper(item.status().toString()))
+ .time("startTimestamp", item.startTimestamp())
+ .time("endTimestamp", item.endTimestamp())
+ .num("attempt", item.attempt())
+ .bool("isReplay", item.isReplay())
+ .str("error", Rec.msg(item.error()))
+ .emit(executionArn);
+ }
+ }
+ }
+
+ /** Single-line JSON record builder: emits every provided key, skipping nulls, then stamps durableExecutionArn. */
+ private static final class Rec {
+ private final StringBuilder sb = new StringBuilder("{");
+
+ Rec(String hook) {
+ raw("plugin", "\"CONFPLUGIN\"");
+ raw("hook", "\"" + hook + "\"");
+ }
+
+ Rec str(String key, String value) {
+ if (value != null) {
+ raw(key, quote(value));
+ }
+ return this;
+ }
+
+ Rec num(String key, Integer value) {
+ if (value != null) {
+ raw(key, value.toString());
+ }
+ return this;
+ }
+
+ Rec bool(String key, boolean value) {
+ raw(key, value ? "true" : "false");
+ return this;
+ }
+
+ Rec time(String key, Instant value) {
+ if (value != null) {
+ raw(key, quote(value.toString()));
+ }
+ return this;
+ }
+
+ private void raw(String key, String jsonValue) {
+ if (sb.length() > 1) {
+ sb.append(", ");
+ }
+ sb.append('"').append(key).append("\": ").append(jsonValue);
+ }
+
+ void emit(String executionArn) {
+ System.out.println(sb.append(PluginSupport.arnField(executionArn)).append('}'));
+ }
+
+ static String upper(String s) {
+ return s == null ? null : s.toUpperCase(Locale.ROOT);
+ }
+
+ static String msg(Throwable t) {
+ if (t == null) {
+ return null;
+ }
+ return t.getMessage() != null ? t.getMessage() : t.toString();
+ }
+
+ static String quote(String s) {
+ StringBuilder b = new StringBuilder("\"");
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"':
+ b.append("\\\"");
+ break;
+ case '\\':
+ b.append("\\\\");
+ break;
+ case '\n':
+ b.append("\\n");
+ break;
+ case '\r':
+ b.append("\\r");
+ break;
+ case '\t':
+ b.append("\\t");
+ break;
+ default:
+ if (c < 0x20) {
+ b.append(String.format("\\u%04x", (int) c));
+ } else {
+ b.append(c);
+ }
+ }
+ }
+ return b.append('"').toString();
+ }
+ }
+}
diff --git a/conformance-tests/src/main/java/plugin/PluginOperationInfoShape.java b/conformance-tests/src/main/java/plugin/PluginOperationInfoShape.java
new file mode 100644
index 000000000..71bfce0c0
--- /dev/null
+++ b/conformance-tests/src/main/java/plugin/PluginOperationInfoShape.java
@@ -0,0 +1,176 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package plugin;
+
+import java.time.Instant;
+import java.util.Locale;
+import software.amazon.lambda.durable.DurableConfig;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.plugin.DurableExecutionPlugin;
+import software.amazon.lambda.durable.plugin.InvocationInfo;
+import software.amazon.lambda.durable.plugin.OperationEndInfo;
+import software.amazon.lambda.durable.plugin.OperationInfo;
+
+/**
+ * 10-20: Operation hook info field shape (CANONICAL DUMP).
+ *
+ * A single step named {@code "greet"} returning the constant {@code "task-a"}. The instrumentation plugin (filtering
+ * to step-type operations) emits ONE single-line JSON record per operation hook event: a canonical dump of that hook's
+ * OWN info parameter, every exposed component mapped to its canonical camelCase name, null / unexposed fields OMITTED.
+ *
+ *
Java's {@link OperationInfo} (operation-start) exposes id/name/type/subType/parentId/startTimestamp/endTimestamp/
+ * status/isReplay; at a LIVE first start {@code status}/{@code startTimestamp} may be unset and are simply omitted, and
+ * the record has no {@code attempt}/{@code result}/{@code error} at all. Java's {@link OperationEndInfo}
+ * (operation-end) adds {@code attempt} and {@code error} but does NOT expose the checkpointed serialized result, so
+ * {@code result} is absent on the end record — that omission is the honest red the requirement produces.
+ */
+@SuppressWarnings("deprecation")
+public class PluginOperationInfoShape extends DurableHandler {
+
+ @Override
+ protected DurableConfig createConfiguration() {
+ return DurableConfig.builder().withPlugins(new OperationShapePlugin()).build();
+ }
+
+ @Override
+ public String handleRequest(Object input, DurableContext context) {
+ return context.step("greet", String.class, stepCtx -> "task-a");
+ }
+
+ private static final class OperationShapePlugin implements DurableExecutionPlugin {
+ private volatile String executionArn;
+
+ @Override
+ public void onInvocationStart(InvocationInfo info) {
+ this.executionArn = info.durableExecutionArn();
+ }
+
+ @Override
+ public void onOperationStart(OperationInfo info) {
+ if (!PluginSupport.isStep(info.type())) {
+ return;
+ }
+ new Rec("operation-start")
+ .str("id", info.id())
+ .str("name", info.name())
+ .str("type", Rec.upper(info.type()))
+ .str("subType", info.subType())
+ .str("parentId", info.parentId())
+ .str("status", Rec.upper(info.status()))
+ .time("startTimestamp", info.startTimestamp())
+ .time("endTimestamp", info.endTimestamp())
+ .bool("isReplay", info.isReplay())
+ .emit(executionArn);
+ }
+
+ @Override
+ public void onOperationEnd(OperationEndInfo info) {
+ if (!PluginSupport.isStep(info.type())) {
+ return;
+ }
+ new Rec("operation-end")
+ .str("id", info.id())
+ .str("name", info.name())
+ .str("type", Rec.upper(info.type()))
+ .str("subType", info.subType())
+ .str("parentId", info.parentId())
+ .str("status", Rec.upper(info.status()))
+ .time("startTimestamp", info.startTimestamp())
+ .time("endTimestamp", info.endTimestamp())
+ .num("attempt", info.attempt())
+ .bool("isReplay", info.isReplay())
+ .str("error", Rec.msg(info.error()))
+ .emit(executionArn);
+ }
+ }
+
+ /** Single-line JSON record builder: emits every provided key, skipping nulls, then stamps durableExecutionArn. */
+ private static final class Rec {
+ private final StringBuilder sb = new StringBuilder("{");
+
+ Rec(String hook) {
+ raw("plugin", "\"CONFPLUGIN\"");
+ raw("hook", "\"" + hook + "\"");
+ }
+
+ Rec str(String key, String value) {
+ if (value != null) {
+ raw(key, quote(value));
+ }
+ return this;
+ }
+
+ Rec num(String key, Integer value) {
+ if (value != null) {
+ raw(key, value.toString());
+ }
+ return this;
+ }
+
+ Rec bool(String key, boolean value) {
+ raw(key, value ? "true" : "false");
+ return this;
+ }
+
+ Rec time(String key, Instant value) {
+ if (value != null) {
+ raw(key, quote(value.toString()));
+ }
+ return this;
+ }
+
+ private void raw(String key, String jsonValue) {
+ if (sb.length() > 1) {
+ sb.append(", ");
+ }
+ sb.append('"').append(key).append("\": ").append(jsonValue);
+ }
+
+ void emit(String executionArn) {
+ System.out.println(sb.append(PluginSupport.arnField(executionArn)).append('}'));
+ }
+
+ static String upper(String s) {
+ return s == null ? null : s.toUpperCase(Locale.ROOT);
+ }
+
+ static String msg(Throwable t) {
+ if (t == null) {
+ return null;
+ }
+ return t.getMessage() != null ? t.getMessage() : t.toString();
+ }
+
+ static String quote(String s) {
+ StringBuilder b = new StringBuilder("\"");
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"':
+ b.append("\\\"");
+ break;
+ case '\\':
+ b.append("\\\\");
+ break;
+ case '\n':
+ b.append("\\n");
+ break;
+ case '\r':
+ b.append("\\r");
+ break;
+ case '\t':
+ b.append("\\t");
+ break;
+ default:
+ if (c < 0x20) {
+ b.append(String.format("\\u%04x", (int) c));
+ } else {
+ b.append(c);
+ }
+ }
+ }
+ return b.append('"').toString();
+ }
+ }
+}
diff --git a/conformance-tests/src/main/java/plugin/PluginSupport.java b/conformance-tests/src/main/java/plugin/PluginSupport.java
index 474f94e6c..5a6879e96 100644
--- a/conformance-tests/src/main/java/plugin/PluginSupport.java
+++ b/conformance-tests/src/main/java/plugin/PluginSupport.java
@@ -27,6 +27,16 @@ static boolean isWait(String type) {
return "WAIT".equals(type);
}
+ /**
+ * Operation type token for context operations (parallel, map, run-in-child-context, wait-for-callback) as reported
+ * by {@code OperationInfo#type()} / {@code UserFunctionStartInfo#type()} ({@code OperationType.CONTEXT}). Both the
+ * parallel parent and its branches report this token; the branch is distinguished by the {@code ParallelBranch}
+ * sub-type.
+ */
+ static boolean isContext(String type) {
+ return "CONTEXT".equals(type);
+ }
+
/**
* Operation type token for step operations as reported by {@code OperationChangeItemInfo#type()}
* ({@code Operation#typeAsString()} straight off the checkpoint response). Compared case-insensitively because it
diff --git a/conformance-tests/template_plugin.yaml b/conformance-tests/template_plugin.yaml
index ecc8cac17..4de478f31 100644
--- a/conformance-tests/template_plugin.yaml
+++ b/conformance-tests/template_plugin.yaml
@@ -304,3 +304,83 @@ Resources:
DurableConfig:
RetentionPeriodInDays: 7
ExecutionTimeout: 300
+
+ PluginInvocationInfoShape:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-19"]
+ Properties:
+ CodeUri: .
+ Handler: plugin.PluginInvocationInfoShape
+ Description: Invocation-start and invocation-end hook info carries the full invocation field set
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ PluginOperationInfoShape:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-20"]
+ Properties:
+ CodeUri: .
+ Handler: plugin.PluginOperationInfoShape
+ Description: Operation-start and operation-end hook info carries the full operation field set
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ PluginAttemptInfoShape:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-21"]
+ Properties:
+ CodeUri: .
+ Handler: plugin.PluginAttemptInfoShape
+ Description: Attempt-start and attempt-end hook info carries the full attempt field set
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ PluginOperationChangeShape:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-22"]
+ Properties:
+ CodeUri: .
+ Handler: plugin.PluginOperationChangeShape
+ Description: Operation-change hook info carries full operation items in the delta and full map
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ PluginContextInfoShape:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-23"]
+ Properties:
+ CodeUri: .
+ Handler: plugin.PluginContextInfoShape
+ Description: Context operation-start and user-function-start hook info carries subType tokens and the children-replay indicator
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
diff --git a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginTest.java b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginTest.java
index fa4fd202e..fa910f718 100644
--- a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginTest.java
+++ b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginTest.java
@@ -19,6 +19,7 @@
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
import java.time.Instant;
+import java.util.Map;
import java.util.ServiceLoader;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -71,8 +72,9 @@ void customInstrumentationName_isUsedForTracerScope() {
.workflowSpanName("Workflow")
.instrumentationName("my-custom-scope")
.build());
- customPlugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
- customPlugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ customPlugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
+ customPlugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = exporter.getFinishedSpanItems();
assertFalse(spans.isEmpty());
@@ -145,13 +147,14 @@ void defaultConstructor_usesGlobalSdkTracerProviderDirectly() {
OpenTelemetrySdk.builder().setTracerProvider(globalTracerProvider).buildAndRegisterGlobal();
var defaultPlugin = new ExecutionOtelPlugin();
- defaultPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ defaultPlugin.onInvocationStart(
+ new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
defaultPlugin.onOperationStart(
new OperationInfo("op-1", "step", "STEP", "Step", null, Instant.now(), null, null, false));
defaultPlugin.onOperationEnd(new OperationEndInfo(
"op-1", "step", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- defaultPlugin.onInvocationEnd(
- new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ defaultPlugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = globalExporter.getFinishedSpanItems();
// Workflow + Invocation + operation = 3
@@ -178,8 +181,9 @@ void executionOtelPluginProvider_isRegisteredAsServiceProvider() {
@Test
void terminalInvocation_exportsWorkflowAndInvocationSpans() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
assertEquals(2, spans.size(), "Terminal invocation should export the Workflow span and the invocation span");
@@ -193,8 +197,9 @@ void terminalInvocation_exportsWorkflowAndInvocationSpans() {
@Test
void spans_preserveConfiguredServiceName() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
for (var span : spanExporter.getFinishedSpanItems()) {
assertEquals(
@@ -207,8 +212,9 @@ void spans_preserveConfiguredServiceName() {
@Test
void workflowSpan_startsAtExecutionStartTime() {
var start = Instant.parse("2026-01-15T08:00:00Z");
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, start));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, start, Map.of(), Map.of()));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var workflowSpan = spanByName(spanExporter.getFinishedSpanItems(), "Workflow");
assertEquals(
@@ -219,8 +225,9 @@ void workflowSpan_startsAtExecutionStartTime() {
@Test
void workflowSpan_hasInternalKind() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
assertEquals(
SpanKind.INTERNAL,
@@ -230,8 +237,9 @@ void workflowSpan_hasInternalKind() {
@Test
void workflowAndInvocationSpans_areIndependentRoots_withoutAmbientContext() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
var workflowSpan = spanByName(spans, "Workflow");
@@ -251,9 +259,10 @@ void invocationStart_usesCurrentSpanContext_whenExtractorReturnsNull() {
SpanContext.create(traceId, parentSpanId, TraceFlags.getSampled(), TraceState.getDefault());
try (var ignored = Span.wrap(parentSpanContext).makeCurrent()) {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
}
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var invocationSpan = spanByName(spanExporter.getFinishedSpanItems(), "Invocation");
assertEquals(traceId, invocationSpan.getTraceId());
@@ -262,8 +271,9 @@ void invocationStart_usesCurrentSpanContext_whenExtractorReturnsNull() {
@Test
void nonTerminalInvocation_doesNotExportWorkflowSpan() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.PENDING, null));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.PENDING, null));
var spans = spanExporter.getFinishedSpanItems();
// Only the invocation span is exported; the Workflow span is not ended on non-terminal status.
@@ -275,8 +285,9 @@ void nonTerminalInvocation_doesNotExportWorkflowSpan() {
@Test
void workflowSpan_exportedOnceAcrossInvocations_sameSpanId() {
// Invocation 1: non-terminal → no Workflow span exported
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.PENDING, null));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.PENDING, null));
assertTrue(
spanExporter.getFinishedSpanItems().stream()
.noneMatch(s -> s.getName().equals("Workflow")),
@@ -284,8 +295,9 @@ void workflowSpan_exportedOnceAcrossInvocations_sameSpanId() {
spanExporter.reset();
// Invocation 2: terminal → Workflow span exported with the deterministic ID
- plugin.onInvocationStart(new InvocationInfo("req-2", ARN, false, Instant.now()));
- plugin.onInvocationEnd(new InvocationEndInfo("req-2", ARN, false, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationStart(new InvocationInfo("req-2", ARN, false, Instant.now(), Map.of(), Map.of()));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-2", ARN, false, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var workflowSpan = spanByName(spanExporter.getFinishedSpanItems(), "Workflow");
assertEquals(StatusCode.OK, workflowSpan.getStatus().getStatusCode());
@@ -296,9 +308,9 @@ void workflowSpan_exportedOnceAcrossInvocations_sameSpanId() {
@Test
void failedInvocation_setsErrorOnBothWorkflowAndInvocationSpans() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
- plugin.onInvocationEnd(
- new InvocationEndInfo("req-1", ARN, true, InvocationStatus.FAILED, new RuntimeException("boom")));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.FAILED, new RuntimeException("boom")));
var spans = spanExporter.getFinishedSpanItems();
assertEquals(StatusCode.ERROR, spanByName(spans, "Workflow").getStatus().getStatusCode());
@@ -308,9 +320,15 @@ void failedInvocation_setsErrorOnBothWorkflowAndInvocationSpans() {
@Test
void retryingInvocation_invocationSpanUnset_workflowNotExported() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
plugin.onInvocationEnd(new InvocationEndInfo(
- "req-1", ARN, true, InvocationStatus.RETRYING, new RuntimeException("transient")));
+ "req-1",
+ ARN,
+ true,
+ Instant.now(),
+ Map.of(),
+ InvocationStatus.RETRYING,
+ new RuntimeException("transient")));
var spans = spanExporter.getFinishedSpanItems();
assertEquals(1, spans.size(), "RETRYING is non-terminal — Workflow span not exported");
@@ -326,12 +344,13 @@ void retryingInvocation_invocationSpanUnset_workflowNotExported() {
@Test
void operationSpan_carriesAttemptNumberAtEnd() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-1", "flaky", "STEP", "Step", null, Instant.now(), null, null, false));
plugin.onOperationEnd(new OperationEndInfo(
"op-1", "flaky", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", 3, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var operationSpan = spanByName(spanExporter.getFinishedSpanItems(), "flaky");
assertEquals(
@@ -344,11 +363,12 @@ void operationSpan_carriesAttemptNumberAtEnd() {
@Test
void continuationOperationSpan_carriesAttemptNumber() {
- plugin.onInvocationStart(new InvocationInfo("req-2", ARN, false, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-2", ARN, false, Instant.now(), Map.of(), Map.of()));
// No matching onOperationStart in this invocation — continuation branch.
plugin.onOperationEnd(new OperationEndInfo(
"op-1", "flaky", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", 2, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-2", ARN, false, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-2", ARN, false, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var operationSpan = spanByName(spanExporter.getFinishedSpanItems(), "flaky");
assertEquals(
@@ -363,11 +383,12 @@ void continuationOperationSpan_carriesAttemptNumber() {
void operationSpan_startsAtOperationStartTimestamp() {
var opStart = Instant.parse("2026-02-01T10:00:00Z");
var opEnd = Instant.parse("2026-02-01T10:00:03Z");
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(new OperationInfo("op-1", "step-a", "STEP", "Step", null, opStart, null, null, false));
plugin.onOperationEnd(new OperationEndInfo(
"op-1", "step-a", "STEP", "Step", null, opStart, opEnd, "SUCCEEDED", null, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var operationSpan = spanByName(spanExporter.getFinishedSpanItems(), "step-a");
assertEquals(
@@ -378,12 +399,13 @@ void operationSpan_startsAtOperationStartTimestamp() {
@Test
void operationSpan_parentedToWorkflow_linkedToInvocation() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), null, null, false));
plugin.onOperationEnd(new OperationEndInfo(
"op-1", "step-a", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
var workflowSpan = spanByName(spans, "Workflow");
@@ -402,16 +424,17 @@ void operationSpan_parentedToWorkflow_linkedToInvocation() {
@Test
void attemptSpan_childOfOperation_linkedToInvocation() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-1", "compute", "STEP", "Step", null, Instant.now(), null, null, false));
plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "compute", "STEP", "Step", null, Instant.now(), false, 1));
+ new UserFunctionStartInfo("op-1", "compute", "STEP", "Step", null, Instant.now(), false, false, 1));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-1", "compute", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
+ "op-1", "compute", "STEP", "Step", null, Instant.now(), Instant.now(), false, false, 1, true, null));
plugin.onOperationEnd(new OperationEndInfo(
"op-1", "compute", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
var operationSpan = spanByName(spans, "compute");
@@ -434,12 +457,24 @@ void attemptSpan_childOfOperation_linkedToInvocation() {
@Test
void attemptSpan_carriesOperationSubtype() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
- plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "process-order", "STEP", "Step", null, Instant.now(), false, 1));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
+ plugin.onUserFunctionStart(new UserFunctionStartInfo(
+ "op-1", "process-order", "STEP", "Step", null, Instant.now(), false, false, 1));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-1", "process-order", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ "op-1",
+ "process-order",
+ "STEP",
+ "Step",
+ null,
+ Instant.now(),
+ Instant.now(),
+ false,
+ false,
+ 1,
+ true,
+ null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var attemptSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> s.getName().contains("process-order"))
@@ -453,7 +488,7 @@ void attemptSpan_carriesOperationSubtype() {
@Test
void childOperation_parentedToParentOperationSpan() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(new OperationInfo(
"op-parent", "my-context", "CONTEXT", "RunInChildContext", null, Instant.now(), null, null, false));
plugin.onOperationStart(new OperationInfo(
@@ -482,7 +517,8 @@ void childOperation_parentedToParentOperationSpan() {
null,
false,
null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
var parentSpan = spanByName(spans, "my-context");
@@ -497,9 +533,9 @@ void childOperation_parentedToParentOperationSpan() {
@Test
void userFunctionFailure_setsErrorOnAttemptSpan() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "failing", "STEP", "Step", null, Instant.now(), false, 1));
+ new UserFunctionStartInfo("op-1", "failing", "STEP", "Step", null, Instant.now(), false, false, 1));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
"op-1",
"failing",
@@ -509,10 +545,12 @@ void userFunctionFailure_setsErrorOnAttemptSpan() {
Instant.now(),
Instant.now(),
false,
+ false,
1,
false,
new RuntimeException("step failed")));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.FAILED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.FAILED, null));
var attemptSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> s.getName().contains("attempt"))
@@ -523,12 +561,13 @@ void userFunctionFailure_setsErrorOnAttemptSpan() {
@Test
void userFunctionSuccess_setsOkOnAttemptSpan() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "compute", "STEP", "Step", null, Instant.now(), false, 1));
+ new UserFunctionStartInfo("op-1", "compute", "STEP", "Step", null, Instant.now(), false, false, 1));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-1", "compute", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ "op-1", "compute", "STEP", "Step", null, Instant.now(), Instant.now(), false, false, 1, true, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var attemptSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> s.getName().contains("attempt"))
@@ -539,12 +578,13 @@ void userFunctionSuccess_setsOkOnAttemptSpan() {
@Test
void operationSuccess_setsOkOnOperationSpan() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-1", "step-ok", "STEP", "Step", null, Instant.now(), null, null, false));
plugin.onOperationEnd(new OperationEndInfo(
"op-1", "step-ok", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var operationSpan = spanByName(spanExporter.getFinishedSpanItems(), "step-ok");
assertEquals(StatusCode.OK, operationSpan.getStatus().getStatusCode());
@@ -555,7 +595,7 @@ void operationEnd_withNonSuccessStatusAndNoError_leavesOperationSpanUnset() {
// onOperationEnd fires for every terminal status. A CANCELLED operation (or an error-less
// FAILED/TIMED_OUT/STOPPED) carries a non-null, non-SUCCEEDED status with a null error. It must NOT be
// stamped OK — the span status stays UNSET.
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-cancel", "step-cancel", "STEP", "Step", null, Instant.now(), null, null, false));
plugin.onOperationEnd(new OperationEndInfo(
@@ -570,7 +610,8 @@ void operationEnd_withNonSuccessStatusAndNoError_leavesOperationSpanUnset() {
null,
false,
null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var operationSpan = spanByName(spanExporter.getFinishedSpanItems(), "step-cancel");
assertEquals(StatusCode.UNSET, operationSpan.getStatus().getStatusCode());
@@ -580,7 +621,7 @@ void operationEnd_withNonSuccessStatusAndNoError_leavesOperationSpanUnset() {
void operationEnd_withoutStart_nonSuccessStatusAndNoError_leavesContinuationSpanUnset() {
// Same guard on the continuation-span branch (operation completed between invocations): an error-less
// TIMED_OUT terminal status must NOT be stamped OK.
- plugin.onInvocationStart(new InvocationInfo("req-2", ARN, false, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-2", ARN, false, Instant.now(), Map.of(), Map.of()));
plugin.onOperationEnd(new OperationEndInfo(
"op-cb-timeout",
"my-callback",
@@ -593,7 +634,8 @@ void operationEnd_withoutStart_nonSuccessStatusAndNoError_leavesContinuationSpan
null,
false,
null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-2", ARN, false, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-2", ARN, false, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var continuationSpan = spanByName(spanExporter.getFinishedSpanItems(), "my-callback");
assertEquals(StatusCode.UNSET, continuationSpan.getStatus().getStatusCode());
@@ -603,12 +645,13 @@ void operationEnd_withoutStart_nonSuccessStatusAndNoError_leavesContinuationSpan
void operationEnd_withNullStatusAndNoError_setsOkOnOperationSpan() {
// A successful statusless virtual (FLAT CONTEXT) operation fires onOperationEnd with a null operation ->
// null status and null error. This is genuine success and must be stamped OK.
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-ctx", "my-ctx", "CONTEXT", null, null, Instant.now(), null, null, false));
plugin.onOperationEnd(new OperationEndInfo(
"op-ctx", "my-ctx", "CONTEXT", null, null, Instant.now(), Instant.now(), null, null, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var operationSpan = spanByName(spanExporter.getFinishedSpanItems(), "my-ctx");
assertEquals(StatusCode.OK, operationSpan.getStatus().getStatusCode());
@@ -616,10 +659,11 @@ void operationEnd_withNullStatusAndNoError_setsOkOnOperationSpan() {
@Test
void operationNotCompleted_notEndedAtInvocationEnd() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-1", "my-wait", "WAIT", "Wait", null, Instant.now(), null, null, false));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.PENDING, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.PENDING, null));
var spans = spanExporter.getFinishedSpanItems();
// Only the invocation span is exported. The still-open operation span is NOT force-ended (no PENDING
@@ -634,10 +678,11 @@ void operationNotCompleted_notEndedAtInvocationEnd() {
@Test
void operationOpenedThenCompletedNextInvocation_exportedOnceOnOperationEnd() {
// Invocation 1: operation opens but does not complete.
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-1", "my-wait", "WAIT", "Wait", null, Instant.now(), null, null, false));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.PENDING, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.PENDING, null));
assertTrue(
spanExporter.getFinishedSpanItems().stream()
.noneMatch(s -> s.getName().equals("my-wait")),
@@ -645,10 +690,11 @@ void operationOpenedThenCompletedNextInvocation_exportedOnceOnOperationEnd() {
spanExporter.reset();
// Invocation 2: the operation completes → materialized once via onOperationEnd, linked to this invocation.
- plugin.onInvocationStart(new InvocationInfo("req-2", ARN, false, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-2", ARN, false, Instant.now(), Map.of(), Map.of()));
plugin.onOperationEnd(new OperationEndInfo(
"op-1", "my-wait", "WAIT", "Wait", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-2", ARN, false, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-2", ARN, false, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
var waitSpans =
@@ -665,17 +711,19 @@ void operationOpenedThenCompletedNextInvocation_exportedOnceOnOperationEnd() {
@Test
void allSpansShareTraceId_acrossInvocations() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-1", "step-1", "STEP", "Step", null, Instant.now(), null, null, false));
plugin.onOperationEnd(new OperationEndInfo(
"op-1", "step-1", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.PENDING, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.PENDING, null));
var firstTraceId = spanExporter.getFinishedSpanItems().get(0).getTraceId();
spanExporter.reset();
- plugin.onInvocationStart(new InvocationInfo("req-2", ARN, false, Instant.now()));
- plugin.onInvocationEnd(new InvocationEndInfo("req-2", ARN, false, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationStart(new InvocationInfo("req-2", ARN, false, Instant.now(), Map.of(), Map.of()));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-2", ARN, false, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var secondSpans = spanExporter.getFinishedSpanItems();
assertTrue(
@@ -685,7 +733,7 @@ void allSpansShareTraceId_acrossInvocations() {
@Test
void operationEnd_withoutStart_createsContinuationSpanWithLink() {
- plugin.onInvocationStart(new InvocationInfo("req-2", ARN, false, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-2", ARN, false, Instant.now(), Map.of(), Map.of()));
// Operation completed between invocations — no matching onOperationStart in this invocation.
plugin.onOperationEnd(new OperationEndInfo(
"op-wait-1",
@@ -699,7 +747,8 @@ void operationEnd_withoutStart_createsContinuationSpanWithLink() {
null,
false,
null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-2", ARN, false, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-2", ARN, false, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
var continuationSpan = spanByName(spans, "my-wait");
@@ -713,8 +762,9 @@ void operationEnd_withoutStart_createsContinuationSpanWithLink() {
@Test
void deterministicWorkflowSpanId_stableAcrossInvocations() {
- plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var firstWorkflowSpanId =
spanByName(spanExporter.getFinishedSpanItems(), "Workflow").getSpanId();
spanExporter.reset();
@@ -728,8 +778,9 @@ void deterministicWorkflowSpanId_stableAcrossInvocations() {
.enableMdc(false)
.workflowSpanName("Workflow")
.build());
- plugin2.onInvocationStart(new InvocationInfo("req-9", ARN, true, Instant.now()));
- plugin2.onInvocationEnd(new InvocationEndInfo("req-9", ARN, true, InvocationStatus.SUCCEEDED, null));
+ plugin2.onInvocationStart(new InvocationInfo("req-9", ARN, true, Instant.now(), Map.of(), Map.of()));
+ plugin2.onInvocationEnd(
+ new InvocationEndInfo("req-9", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var secondWorkflowSpanId =
spanByName(exporter2.getFinishedSpanItems(), "Workflow").getSpanId();
@@ -753,8 +804,9 @@ void sampling_disabled_producesNoSpans() {
.enableMdc(false)
.workflowSpanName("Workflow")
.build());
- sampledPlugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
- sampledPlugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ sampledPlugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
+ sampledPlugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
assertTrue(exporter.getFinishedSpanItems().isEmpty(), "No spans should be exported with 0% sampling");
}
@@ -771,12 +823,13 @@ void xrayExtraction_allSpansShareExtractedTraceId() {
.enableMdc(false)
.workflowSpanName("Workflow")
.build());
- xrayPlugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ xrayPlugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
xrayPlugin.onOperationStart(
new OperationInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), null, null, false));
xrayPlugin.onOperationEnd(new OperationEndInfo(
"op-1", "step-a", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- xrayPlugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ xrayPlugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = exporter.getFinishedSpanItems();
assertTrue(spans.size() >= 3, "Workflow + invocation + operation spans expected");
@@ -798,8 +851,9 @@ void xrayExtraction_withParentSpanId_invocationSpanHasCorrectParent() {
.workflowSpanName("Workflow")
.build());
- xrayPlugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
- xrayPlugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ xrayPlugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now(), Map.of(), Map.of()));
+ xrayPlugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", ARN, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = exporter.getFinishedSpanItems();
var workflowSpan = spanByName(spans, "Workflow");
diff --git a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginTest.java b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginTest.java
index fed449c40..d648c2ae1 100644
--- a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginTest.java
+++ b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginTest.java
@@ -28,6 +28,7 @@
import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder;
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
import java.time.Instant;
+import java.util.Map;
import java.util.ServiceLoader;
import java.util.concurrent.TimeUnit;
import java.util.function.BiFunction;
@@ -96,13 +97,14 @@ void defaultConstructor_usesGlobalSdkTracerProviderDirectly() {
OpenTelemetrySdk.builder().setTracerProvider(globalTracerProvider).buildAndRegisterGlobal();
var defaultPlugin = new InvocationOtelPlugin();
- defaultPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ defaultPlugin.onInvocationStart(
+ new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
defaultPlugin.onOperationStart(
new OperationInfo("op-1", "step", "STEP", "Step", null, Instant.now(), null, null, false));
defaultPlugin.onOperationEnd(new OperationEndInfo(
"op-1", "step", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- defaultPlugin.onInvocationEnd(
- new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ defaultPlugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = globalExporter.getFinishedSpanItems();
// Plugin creates Workflow + Invocation + operation spans
@@ -134,13 +136,14 @@ public ContextPropagators getPropagators() {
});
var defaultPlugin = new InvocationOtelPlugin();
- defaultPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ defaultPlugin.onInvocationStart(
+ new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
defaultPlugin.onOperationStart(
new OperationInfo("op-1", "step", "STEP", "Step", null, Instant.now(), null, null, false));
defaultPlugin.onOperationEnd(new OperationEndInfo(
"op-1", "step", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- defaultPlugin.onInvocationEnd(
- new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ defaultPlugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = globalExporter.getFinishedSpanItems();
// Plugin creates Workflow + Invocation + operation spans
@@ -219,9 +222,10 @@ void invocationStart_usesCurrentSpanContext_whenExtractorReturnsNull() {
SpanContext.create(traceId, parentSpanId, TraceFlags.getSampled(), TraceState.getDefault());
try (var ignored = Span.wrap(parentSpanContext).makeCurrent()) {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
}
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var invocationSpan = spanExporter.getFinishedSpanItems().stream()
.filter(span -> span.getName().equals("Invocation"))
@@ -234,11 +238,18 @@ void invocationStart_usesCurrentSpanContext_whenExtractorReturnsNull() {
@Test
void invocationStart_and_end_createsSpan() {
plugin.onInvocationStart(new InvocationInfo(
- "req-123", "arn:aws:lambda:us-east-1:123:function:test:$LATEST/durable/exec1", true, Instant.now()));
+ "req-123",
+ "arn:aws:lambda:us-east-1:123:function:test:$LATEST/durable/exec1",
+ true,
+ Instant.now(),
+ Map.of(),
+ Map.of()));
plugin.onInvocationEnd(new InvocationEndInfo(
"req-123",
"arn:aws:lambda:us-east-1:123:function:test:$LATEST/durable/exec1",
true,
+ Instant.now(),
+ Map.of(),
InvocationStatus.SUCCEEDED,
null));
@@ -261,9 +272,10 @@ void customInstrumentationName_isUsedForTracerScope() {
.workflowSpanName("Workflow")
.instrumentationName("my-custom-scope")
.build());
- customPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
- customPlugin.onInvocationEnd(
- new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ customPlugin.onInvocationStart(
+ new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
+ customPlugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = exporter.getFinishedSpanItems();
assertFalse(spans.isEmpty());
@@ -320,8 +332,9 @@ void configOnlyConstructor_rejectsExplicitProviderSource() {
@Test
void invocationSpan_hasInternalKind() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var span = spanExporter.getFinishedSpanItems().get(0);
assertEquals(SpanKind.INTERNAL, span.getKind(), "Invocation span must be INTERNAL kind");
@@ -329,7 +342,7 @@ void invocationSpan_hasInternalKind() {
@Test
void operationSpanName_usesOperationName_withoutPrefix() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-1", "create-greeting", "STEP", "Step", null, Instant.now(), null, null, false));
plugin.onOperationEnd(new OperationEndInfo(
@@ -344,7 +357,8 @@ void operationSpanName_usesOperationName_withoutPrefix() {
null,
false,
null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var operationSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> s.getName().equals("create-greeting"))
@@ -358,12 +372,24 @@ void operationSpanName_usesOperationName_withoutPrefix() {
@Test
void attemptSpanName_usesOperationNameWithAttemptNumber() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
- plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "process-order", "STEP", "Step", null, Instant.now(), false, 1));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
+ plugin.onUserFunctionStart(new UserFunctionStartInfo(
+ "op-1", "process-order", "STEP", "Step", null, Instant.now(), false, false, 1));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-1", "process-order", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ "op-1",
+ "process-order",
+ "STEP",
+ "Step",
+ null,
+ Instant.now(),
+ Instant.now(),
+ false,
+ false,
+ 1,
+ true,
+ null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var attemptSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> s.getName().contains("attempt"))
@@ -377,13 +403,14 @@ void attemptSpanName_usesOperationNameWithAttemptNumber() {
@Test
void operationEnd_withAttempt_stampsAttemptNumberOnOperationSpan() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-1", "flaky", "STEP", "Step", null, Instant.now(), null, null, false));
plugin.onOperationEnd(new OperationEndInfo(
"op-1", "flaky", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", 3, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var operationSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> s.getName().equals("flaky"))
@@ -397,12 +424,24 @@ void operationEnd_withAttempt_stampsAttemptNumberOnOperationSpan() {
@Test
void attemptSpan_carriesOperationSubtype() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
- plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "process-order", "STEP", "Step", null, Instant.now(), false, 1));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
+ plugin.onUserFunctionStart(new UserFunctionStartInfo(
+ "op-1", "process-order", "STEP", "Step", null, Instant.now(), false, false, 1));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-1", "process-order", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ "op-1",
+ "process-order",
+ "STEP",
+ "Step",
+ null,
+ Instant.now(),
+ Instant.now(),
+ false,
+ false,
+ 1,
+ true,
+ null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var attemptSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> s.getName().contains("process-order"))
@@ -416,12 +455,13 @@ void attemptSpan_carriesOperationSubtype() {
@Test
void operationEnd_withoutMatchingStart_stampsAttemptNumberOnContinuationSpan() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
// No onOperationStart in this invocation → onOperationEnd takes the continuation-span branch.
plugin.onOperationEnd(new OperationEndInfo(
"op-1", "flaky", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", 2, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var continuationSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> s.getName().equals("flaky"))
@@ -439,9 +479,15 @@ void operationEnd_withoutMatchingStart_stampsAttemptNumberOnContinuationSpan() {
@Test
void invocationEnd_withFailure_setsErrorStatus() {
- plugin.onInvocationStart(new InvocationInfo("req-123", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-123", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
plugin.onInvocationEnd(new InvocationEndInfo(
- "req-123", "arn:exec1", true, InvocationStatus.FAILED, new RuntimeException("boom")));
+ "req-123",
+ "arn:exec1",
+ true,
+ Instant.now(),
+ Map.of(),
+ InvocationStatus.FAILED,
+ new RuntimeException("boom")));
var spans = spanExporter.getFinishedSpanItems();
assertEquals(2, spans.size()); // invocation + Workflow
@@ -450,9 +496,15 @@ void invocationEnd_withFailure_setsErrorStatus() {
@Test
void invocationEnd_withRetrying_leavesStatusUnset() {
- plugin.onInvocationStart(new InvocationInfo("req-123", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-123", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
plugin.onInvocationEnd(new InvocationEndInfo(
- "req-123", "arn:exec1", true, InvocationStatus.RETRYING, new RuntimeException("transient")));
+ "req-123",
+ "arn:exec1",
+ true,
+ Instant.now(),
+ Map.of(),
+ InvocationStatus.RETRYING,
+ new RuntimeException("transient")));
var spans = spanExporter.getFinishedSpanItems();
assertEquals(1, spans.size());
@@ -464,7 +516,7 @@ void invocationEnd_withRetrying_leavesStatusUnset() {
@Test
void operationStart_createsSpan_operationEnd_endsIt() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
var start = Instant.parse("2026-06-01T10:00:00Z");
var end = Instant.parse("2026-06-01T10:00:05Z");
@@ -477,7 +529,8 @@ void operationStart_createsSpan_operationEnd_endsIt() {
plugin.onOperationEnd(new OperationEndInfo(
"op-hash-1", "my-step", "STEP", "Step", null, start, end, "SUCCEEDED", null, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
assertEquals(3, spans.size()); // operation + invocation + Workflow
@@ -491,15 +544,16 @@ void operationStart_createsSpan_operationEnd_endsIt() {
@Test
void userFunctionStart_and_end_createsAttemptSpan() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "compute", "STEP", "Step", null, Instant.now(), false, 1));
+ new UserFunctionStartInfo("op-1", "compute", "STEP", "Step", null, Instant.now(), false, false, 1));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-1", "compute", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
+ "op-1", "compute", "STEP", "Step", null, Instant.now(), Instant.now(), false, false, 1, true, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
assertEquals(3, spans.size()); // attempt + invocation + Workflow
@@ -514,10 +568,10 @@ void userFunctionStart_and_end_createsAttemptSpan() {
@Test
void userFunctionEnd_withFailure_setsErrorOnAttemptSpan() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "failing", "STEP", "Step", null, Instant.now(), false, 1));
+ new UserFunctionStartInfo("op-1", "failing", "STEP", "Step", null, Instant.now(), false, false, 1));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
"op-1",
@@ -528,11 +582,13 @@ void userFunctionEnd_withFailure_setsErrorOnAttemptSpan() {
Instant.now(),
Instant.now(),
false,
+ false,
1,
false,
new RuntimeException("step failed")));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.FAILED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.FAILED, null));
var attemptSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> s.getName().contains("attempt"))
@@ -543,14 +599,15 @@ void userFunctionEnd_withFailure_setsErrorOnAttemptSpan() {
@Test
void userFunctionEnd_withSuccess_setsOkOnAttemptSpan() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "compute", "STEP", "Step", null, Instant.now(), false, 1));
+ new UserFunctionStartInfo("op-1", "compute", "STEP", "Step", null, Instant.now(), false, false, 1));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-1", "compute", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
+ "op-1", "compute", "STEP", "Step", null, Instant.now(), Instant.now(), false, false, 1, true, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var attemptSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> s.getName().contains("attempt"))
@@ -561,14 +618,15 @@ void userFunctionEnd_withSuccess_setsOkOnAttemptSpan() {
@Test
void operationEnd_withSuccess_setsOkOnOperationSpan() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-1", "step-ok", "STEP", "Step", null, Instant.now(), null, null, false));
plugin.onOperationEnd(new OperationEndInfo(
"op-1", "step-ok", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var operationSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> "step-ok".equals(s.getName()))
@@ -584,7 +642,7 @@ void operationEnd_withNonSuccessStatusAndNoError_leavesOperationSpanUnset() {
// onOperationEnd fires for every terminal status. A CANCELLED operation (or an error-less
// FAILED/TIMED_OUT/STOPPED) carries a non-null, non-SUCCEEDED status with a null error. It must NOT be
// stamped OK — the span status stays UNSET.
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-cancel", "step-cancel", "STEP", "Step", null, Instant.now(), null, null, false));
@@ -601,7 +659,8 @@ void operationEnd_withNonSuccessStatusAndNoError_leavesOperationSpanUnset() {
false,
null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var operationSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> "step-cancel".equals(s.getName()))
@@ -614,7 +673,7 @@ void operationEnd_withNonSuccessStatusAndNoError_leavesOperationSpanUnset() {
void operationEnd_withoutMatchingStart_nonSuccessStatusAndNoError_leavesContinuationSpanUnset() {
// Same guard on the continuation-span branch (operation completed between invocations): an error-less
// TIMED_OUT terminal status must NOT be stamped OK.
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationEnd(new OperationEndInfo(
"op-cb-timeout",
@@ -629,7 +688,8 @@ void operationEnd_withoutMatchingStart_nonSuccessStatusAndNoError_leavesContinua
false,
null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var continuationSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> s.getName().contains("callback"))
@@ -642,14 +702,15 @@ void operationEnd_withoutMatchingStart_nonSuccessStatusAndNoError_leavesContinua
void operationEnd_withNullStatusAndNoError_setsOkOnOperationSpan() {
// A successful statusless virtual (FLAT CONTEXT) operation fires onOperationEnd with a null operation ->
// null status and null error. This is genuine success and must be stamped OK.
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-ctx", "my-ctx", "CONTEXT", null, null, Instant.now(), null, null, false));
plugin.onOperationEnd(new OperationEndInfo(
"op-ctx", "my-ctx", "CONTEXT", null, null, Instant.now(), Instant.now(), null, null, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var operationSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> "my-ctx".equals(s.getName()))
@@ -661,15 +722,15 @@ void operationEnd_withNullStatusAndNoError_setsOkOnOperationSpan() {
@Test
void fullLifecycle_producesCorrectSpanHierarchy() {
var arn = "arn:aws:lambda:us-east-1:123:function:test:$LATEST/durable/exec1";
- plugin.onInvocationStart(new InvocationInfo("req-1", arn, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", arn, true, Instant.now(), Map.of(), Map.of()));
// Step 1: operation starts, user function runs, operation completes
plugin.onOperationStart(
new OperationInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), null, null, false));
plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), false, 1));
+ new UserFunctionStartInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), false, false, 1));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-1", "step-a", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
+ "op-1", "step-a", "STEP", "Step", null, Instant.now(), Instant.now(), false, false, 1, true, null));
plugin.onOperationEnd(new OperationEndInfo(
"op-1", "step-a", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
@@ -677,13 +738,14 @@ void fullLifecycle_producesCorrectSpanHierarchy() {
plugin.onOperationStart(
new OperationInfo("op-2", "step-b", "STEP", "Step", null, Instant.now(), null, null, false));
plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-2", "step-b", "STEP", "Step", null, Instant.now(), false, 1));
+ new UserFunctionStartInfo("op-2", "step-b", "STEP", "Step", null, Instant.now(), false, false, 1));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-2", "step-b", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
+ "op-2", "step-b", "STEP", "Step", null, Instant.now(), Instant.now(), false, false, 1, true, null));
plugin.onOperationEnd(new OperationEndInfo(
"op-2", "step-b", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", arn, true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", arn, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
// 2 attempt spans + 2 operation spans + 1 invocation span + 1 Workflow span = 6
@@ -698,15 +760,17 @@ void fullLifecycle_producesCorrectSpanHierarchy() {
void deterministicIds_sameExecutionProducesSameTraceId() {
var arn = "arn:aws:lambda:us-east-1:123:function:test:$LATEST/durable/exec1";
- plugin.onInvocationStart(new InvocationInfo("req-1", arn, true, Instant.now()));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", arn, true, InvocationStatus.PENDING, null));
+ plugin.onInvocationStart(new InvocationInfo("req-1", arn, true, Instant.now(), Map.of(), Map.of()));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", arn, true, Instant.now(), Map.of(), InvocationStatus.PENDING, null));
var firstTraceId = spanExporter.getFinishedSpanItems().get(0).getTraceId();
spanExporter.reset();
// Second invocation of same execution
- plugin.onInvocationStart(new InvocationInfo("req-2", arn, false, Instant.now()));
- plugin.onInvocationEnd(new InvocationEndInfo("req-2", arn, false, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationStart(new InvocationInfo("req-2", arn, false, Instant.now(), Map.of(), Map.of()));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-2", arn, false, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var secondTraceId = spanExporter.getFinishedSpanItems().get(0).getTraceId();
@@ -715,14 +779,15 @@ void deterministicIds_sameExecutionProducesSameTraceId() {
@Test
void operationNotCompleted_spanEndedAtInvocationEnd() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
// Operation starts but never completes (e.g., wait operation, invocation suspends)
plugin.onOperationStart(
new OperationInfo("op-1", "my-wait", "WAIT", "Wait", null, Instant.now(), null, null, false));
// Invocation ends without onOperationEnd being called
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.PENDING, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.PENDING, null));
var spans = spanExporter.getFinishedSpanItems();
// Should have: operation span (ended at invocation end) + invocation span
@@ -738,11 +803,12 @@ void operationNotCompleted_spanEndedAtInvocationEnd() {
@Test
void operationStart_withStatus_preservesStatus() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", false, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", false, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-1", "my-step", "STEP", "Step", null, Instant.now(), null, "PENDING", true));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", false, InvocationStatus.PENDING, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", false, Instant.now(), Map.of(), InvocationStatus.PENDING, null));
var operationSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> "my-step".equals(s.getName()))
@@ -755,15 +821,16 @@ void operationStart_withStatus_preservesStatus() {
void invocationEnd_closesNestedSpansChildFirst() {
var parentId = "op-parent";
var childId = "op-child";
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(new OperationInfo(
parentId, "parent-context", "CONTEXT", "RunInChildContext", null, Instant.now(), null, null, false));
plugin.onOperationStart(
new OperationInfo(childId, "child-step", "STEP", "Step", parentId, Instant.now(), null, null, false));
- plugin.onUserFunctionStart(
- new UserFunctionStartInfo(childId, "child-step", "STEP", "Step", parentId, Instant.now(), false, 1));
+ plugin.onUserFunctionStart(new UserFunctionStartInfo(
+ childId, "child-step", "STEP", "Step", parentId, Instant.now(), false, false, 1));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.PENDING, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.PENDING, null));
var parentSpan = spanByName("parent-context");
var childSpan = spanByName("child-step");
@@ -795,15 +862,16 @@ void sampling_disabled_producesNoSpans() {
.enableMdc(false)
.build());
- sampledPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ sampledPlugin.onInvocationStart(
+ new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
sampledPlugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "step", "STEP", "Step", null, Instant.now(), false, 1));
+ new UserFunctionStartInfo("op-1", "step", "STEP", "Step", null, Instant.now(), false, false, 1));
sampledPlugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-1", "step", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
+ "op-1", "step", "STEP", "Step", null, Instant.now(), Instant.now(), false, false, 1, true, null));
sampledPlugin.onOperationEnd(new OperationEndInfo(
"op-1", "step", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- sampledPlugin.onInvocationEnd(
- new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ sampledPlugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
assertTrue(spanExporter.getFinishedSpanItems().isEmpty(), "No spans should be exported with 0% sampling");
}
@@ -823,8 +891,9 @@ void xrayExtraction_usesExtractedTraceId_overArnDerived() {
.enableMdc(false)
.build());
- xrayPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
- xrayPlugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ xrayPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
+ xrayPlugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
assertEquals(2, spans.size()); // invocation + Workflow
@@ -844,16 +913,17 @@ void xrayExtraction_allSpansShareExtractedTraceId() {
.enableMdc(false)
.build());
- xrayPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ xrayPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
xrayPlugin.onOperationStart(
new OperationInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), null, null, false));
xrayPlugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), false, 1));
+ new UserFunctionStartInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), false, false, 1));
xrayPlugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-1", "step-a", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
+ "op-1", "step-a", "STEP", "Step", null, Instant.now(), Instant.now(), false, false, 1, true, null));
xrayPlugin.onOperationEnd(new OperationEndInfo(
"op-1", "step-a", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- xrayPlugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ xrayPlugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
assertTrue(spans.size() >= 2, "Should have invocation + operation + attempt spans");
@@ -876,8 +946,9 @@ void xrayExtraction_withParentSpanId_invocationSpanHasCorrectParent() {
.enableMdc(false)
.build());
- xrayPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
- xrayPlugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ xrayPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
+ xrayPlugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
assertEquals(2, spans.size()); // invocation + Workflow
@@ -903,8 +974,9 @@ void xrayExtraction_withoutParentSpanId_invocationSpanIsRoot() {
.enableMdc(false)
.build());
- xrayPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
- xrayPlugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ xrayPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
+ xrayPlugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
assertEquals(2, spans.size()); // invocation + Workflow
@@ -936,21 +1008,23 @@ void xrayExtraction_multipleInvocations_sameTraceId_unifiedTrace() {
.build());
// First invocation
- xrayPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ xrayPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
xrayPlugin.onOperationStart(
new OperationInfo("op-1", "step-1", "STEP", "Step", null, Instant.now(), null, null, false));
xrayPlugin.onOperationEnd(new OperationEndInfo(
"op-1", "step-1", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- xrayPlugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.PENDING, null));
+ xrayPlugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.PENDING, null));
// Second invocation (same execution, same X-Ray Root from backend)
- xrayPlugin.onInvocationStart(new InvocationInfo("req-2", "arn:exec1", false, Instant.now()));
+ xrayPlugin.onInvocationStart(
+ new InvocationInfo("req-2", "arn:exec1", false, Instant.now(), Map.of(), Map.of()));
xrayPlugin.onOperationStart(
new OperationInfo("op-2", "step-2", "STEP", "Step", null, Instant.now(), null, null, false));
xrayPlugin.onOperationEnd(new OperationEndInfo(
"op-2", "step-2", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- xrayPlugin.onInvocationEnd(
- new InvocationEndInfo("req-2", "arn:exec1", false, InvocationStatus.SUCCEEDED, null));
+ xrayPlugin.onInvocationEnd(new InvocationEndInfo(
+ "req-2", "arn:exec1", false, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
assertTrue(spans.size() >= 4, "Should have spans from both invocations");
@@ -972,8 +1046,9 @@ void xrayExtraction_nullExtractor_fallsBackToArnDerived() {
.build());
var arn = "arn:aws:lambda:us-east-1:123:function:test:$LATEST/durable/exec1";
- noXrayPlugin.onInvocationStart(new InvocationInfo("req-1", arn, true, Instant.now()));
- noXrayPlugin.onInvocationEnd(new InvocationEndInfo("req-1", arn, true, InvocationStatus.SUCCEEDED, null));
+ noXrayPlugin.onInvocationStart(new InvocationInfo("req-1", arn, true, Instant.now(), Map.of(), Map.of()));
+ noXrayPlugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", arn, true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
assertEquals(2, spans.size()); // invocation + Workflow
@@ -1004,8 +1079,9 @@ void xrayExtraction_extractedTraceIdMatchesXrayConversion() {
.enableMdc(false)
.build());
- xrayPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
- xrayPlugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ xrayPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
+ xrayPlugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
assertEquals(expectedOtelTraceId, spans.get(0).getTraceId());
@@ -1015,7 +1091,7 @@ void xrayExtraction_extractedTraceIdMatchesXrayConversion() {
@Test
void operationEnd_withoutMatchingStart_createsContinuationSpanWithLink() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
// onOperationEnd without a prior onOperationStart — operation completed between invocations
plugin.onOperationEnd(new OperationEndInfo(
@@ -1031,7 +1107,8 @@ void operationEnd_withoutMatchingStart_createsContinuationSpanWithLink() {
false,
null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
assertEquals(3, spans.size()); // continuation + invocation + Workflow
@@ -1046,7 +1123,7 @@ void operationEnd_withoutMatchingStart_createsContinuationSpanWithLink() {
@Test
void operationEnd_withoutMatchingStart_startsWithinCurrentInvocation() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
var operationStart = Instant.EPOCH;
var operationEnd = operationStart.plusSeconds(60);
@@ -1065,7 +1142,8 @@ void operationEnd_withoutMatchingStart_startsWithinCurrentInvocation() {
false,
null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
var continuationSpan = spans.stream()
@@ -1084,7 +1162,7 @@ void operationEnd_withoutMatchingStart_startsWithinCurrentInvocation() {
@Test
void operationEnd_withoutMatchingStart_withError_setsErrorStatus() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationEnd(new OperationEndInfo(
"op-cb-1",
@@ -1099,7 +1177,8 @@ void operationEnd_withoutMatchingStart_withError_setsErrorStatus() {
false,
new RuntimeException("timed out")));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var continuationSpan = spanExporter.getFinishedSpanItems().stream()
.filter(s -> s.getName().contains("callback"))
@@ -1113,14 +1192,14 @@ void operationEnd_withoutMatchingStart_withError_setsErrorStatus() {
@Test
void contextOperation_doesNotCreateAttemptSpan() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
// Create operation span first so the CONTEXT user function has a parent
plugin.onOperationStart(new OperationInfo(
"op-1", "child-ctx", "CONTEXT", "RunInChildContext", null, Instant.now(), null, null, false));
plugin.onUserFunctionStart(new UserFunctionStartInfo(
- "op-1", "child-ctx", "CONTEXT", "RunInChildContext", null, Instant.now(), false, null));
+ "op-1", "child-ctx", "CONTEXT", "RunInChildContext", null, Instant.now(), false, false, null));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
"op-1",
@@ -1131,11 +1210,13 @@ void contextOperation_doesNotCreateAttemptSpan() {
Instant.now(),
Instant.now(),
false,
+ false,
null,
false,
new SuspendExecutionException()));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.PENDING, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.PENDING, null));
// Should only have the operation span + invocation span — no attempt span
var spans = spanExporter.getFinishedSpanItems();
@@ -1148,14 +1229,15 @@ void contextOperation_doesNotCreateAttemptSpan() {
@Test
void attemptSpan_endedAtInvocationEnd_whenUserFunctionEndNotCalled() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
// Start attempt but never call onUserFunctionEnd (simulates crash before end hook)
plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "running", "STEP", "Step", null, Instant.now(), false, 1));
+ new UserFunctionStartInfo("op-1", "running", "STEP", "Step", null, Instant.now(), false, false, 1));
// Invocation ends — attempt span should be cleaned up
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.PENDING, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.PENDING, null));
var spans = spanExporter.getFinishedSpanItems();
var attemptSpan = spans.stream()
@@ -1169,7 +1251,7 @@ void attemptSpan_endedAtInvocationEnd_whenUserFunctionEndNotCalled() {
@Test
void childOperation_parentedToParentOperationSpan() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
// Parent context operation
plugin.onOperationStart(new OperationInfo(
@@ -1204,7 +1286,8 @@ void childOperation_parentedToParentOperationSpan() {
false,
null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var spans = spanExporter.getFinishedSpanItems();
@@ -1230,18 +1313,19 @@ void multiInvocation_stepWaitStep_producesCorrectSpans() {
var arn = "arn:aws:lambda:us-east-1:123:function:test:$LATEST/durable/exec1";
// Invocation 1: step completes, wait starts
- plugin.onInvocationStart(new InvocationInfo("req-1", arn, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", arn, true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-1", "step-A", "STEP", "Step", null, Instant.now(), null, null, false));
plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "step-A", "STEP", "Step", null, Instant.now(), false, 1));
+ new UserFunctionStartInfo("op-1", "step-A", "STEP", "Step", null, Instant.now(), false, false, 1));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-1", "step-A", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
+ "op-1", "step-A", "STEP", "Step", null, Instant.now(), Instant.now(), false, false, 1, true, null));
plugin.onOperationEnd(new OperationEndInfo(
"op-1", "step-A", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
plugin.onOperationStart(
new OperationInfo("op-2", "pause", "WAIT", "Wait", null, Instant.now(), null, null, false));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", arn, true, InvocationStatus.PENDING, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", arn, true, Instant.now(), Map.of(), InvocationStatus.PENDING, null));
// Invocation 1 should have: step op + step attempt + wait (PENDING) + invocation = 4
assertEquals(4, spanExporter.getFinishedSpanItems().size());
@@ -1250,18 +1334,19 @@ void multiInvocation_stepWaitStep_producesCorrectSpans() {
spanExporter.reset();
// Invocation 2: wait completed between invocations, new step runs
- plugin.onInvocationStart(new InvocationInfo("req-2", arn, false, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-2", arn, false, Instant.now(), Map.of(), Map.of()));
plugin.onOperationEnd(new OperationEndInfo(
"op-2", "pause", "WAIT", "Wait", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
plugin.onOperationStart(
new OperationInfo("op-3", "step-B", "STEP", "Step", null, Instant.now(), null, null, false));
plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-3", "step-B", "STEP", "Step", null, Instant.now(), false, 1));
+ new UserFunctionStartInfo("op-3", "step-B", "STEP", "Step", null, Instant.now(), false, false, 1));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-3", "step-B", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
+ "op-3", "step-B", "STEP", "Step", null, Instant.now(), Instant.now(), false, false, 1, true, null));
plugin.onOperationEnd(new OperationEndInfo(
"op-3", "step-B", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-2", arn, false, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-2", arn, false, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var inv2Spans = spanExporter.getFinishedSpanItems();
// wait continuation + step-B op + step-B attempt + invocation + Workflow = 5
@@ -1286,11 +1371,11 @@ void crossInvocation_stepRetry_attemptsParentedToRespectiveInvocations() {
var arn = "arn:aws:lambda:us-east-1:123:function:test:$LATEST/durable/exec1";
// Invocation 1: step starts, attempt 1 fails, invocation suspended during retry poll
- plugin.onInvocationStart(new InvocationInfo("req-1", arn, true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", arn, true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-1", "process-payment", "STEP", "Step", null, Instant.now(), null, null, false));
- plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "process-payment", "STEP", "Step", null, Instant.now(), false, 1));
+ plugin.onUserFunctionStart(new UserFunctionStartInfo(
+ "op-1", "process-payment", "STEP", "Step", null, Instant.now(), false, false, 1));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
"op-1",
"process-payment",
@@ -1300,10 +1385,12 @@ void crossInvocation_stepRetry_attemptsParentedToRespectiveInvocations() {
Instant.now(),
Instant.now(),
false,
+ false,
1,
false,
new RuntimeException("payment failed")));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", arn, true, InvocationStatus.PENDING, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-1", arn, true, Instant.now(), Map.of(), InvocationStatus.PENDING, null));
var inv1Spans = spanExporter.getFinishedSpanItems();
// operation span (PENDING) + attempt 1 span + invocation span = 3
@@ -1328,14 +1415,25 @@ void crossInvocation_stepRetry_attemptsParentedToRespectiveInvocations() {
spanExporter.reset();
// Invocation 2: step is replayed (continuation), attempt 2 executes and succeeds
- plugin.onInvocationStart(new InvocationInfo("req-2", arn, false, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-2", arn, false, Instant.now(), Map.of(), Map.of()));
// isReplay=true: this operation already exists in the execution state
plugin.onOperationStart(
new OperationInfo("op-1", "process-payment", "STEP", "Step", null, Instant.now(), null, null, true));
- plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "process-payment", "STEP", "Step", null, Instant.now(), false, 2));
+ plugin.onUserFunctionStart(new UserFunctionStartInfo(
+ "op-1", "process-payment", "STEP", "Step", null, Instant.now(), false, false, 2));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-1", "process-payment", "STEP", "Step", null, Instant.now(), Instant.now(), false, 2, true, null));
+ "op-1",
+ "process-payment",
+ "STEP",
+ "Step",
+ null,
+ Instant.now(),
+ Instant.now(),
+ false,
+ false,
+ 2,
+ true,
+ null));
plugin.onOperationEnd(new OperationEndInfo(
"op-1",
"process-payment",
@@ -1348,7 +1446,8 @@ void crossInvocation_stepRetry_attemptsParentedToRespectiveInvocations() {
null,
false,
null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-2", arn, false, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(
+ new InvocationEndInfo("req-2", arn, false, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var inv2Spans = spanExporter.getFinishedSpanItems();
// operation span + attempt 2 span + invocation span + Workflow span = 4
@@ -1394,8 +1493,9 @@ void crossInvocation_stepRetry_attemptsParentedToRespectiveInvocations() {
@Test
void workflowSpan_exportedOnTerminal_internal_deterministicId() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec-wf", true, Instant.now()));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec-wf", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec-wf", true, Instant.now(), Map.of(), Map.of()));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec-wf", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var workflow = spanByName("Workflow");
assertEquals(SpanKind.INTERNAL, workflow.getKind(), "Workflow span must be INTERNAL");
@@ -1405,8 +1505,9 @@ void workflowSpan_exportedOnTerminal_internal_deterministicId() {
@Test
void workflowSpan_notExportedOnNonTerminal() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.PENDING, null));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.PENDING, null));
assertTrue(
spanExporter.getFinishedSpanItems().stream()
@@ -1416,16 +1517,17 @@ void workflowSpan_notExportedOnNonTerminal() {
@Test
void operationAndAttemptSpans_linkToWorkflowSpan() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec-wf", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec-wf", true, Instant.now(), Map.of(), Map.of()));
plugin.onOperationStart(
new OperationInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), null, null, false));
plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), false, 1));
+ new UserFunctionStartInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), false, false, 1));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-1", "step-a", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
+ "op-1", "step-a", "STEP", "Step", null, Instant.now(), Instant.now(), false, false, 1, true, null));
plugin.onOperationEnd(new OperationEndInfo(
"op-1", "step-a", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", 1, false, null));
- plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec-wf", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec-wf", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var workflowId = spanByName("Workflow").getSpanId();
var operationSpan = spanByName("step-a");
@@ -1449,12 +1551,13 @@ void operationLinksToWorkflow_withXRayContext() {
() -> new ExtractedContext("5759e988bd862e3fe1be46a994272793", "53995c3f42cd8ad8"))
.enableMdc(false)
.build());
- xrayPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ xrayPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
xrayPlugin.onOperationStart(
new OperationInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), null, null, false));
xrayPlugin.onOperationEnd(new OperationEndInfo(
"op-1", "step-a", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", null, false, null));
- xrayPlugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ xrayPlugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
var workflowId = exporter.getFinishedSpanItems().stream()
.filter(s -> s.getName().equals("Workflow"))
@@ -1481,9 +1584,10 @@ void workflowSpanName_isConfigurable() {
.enableMdc(false)
.workflowSpanName("MyWorkflow")
.build());
- customPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
- customPlugin.onInvocationEnd(
- new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+ customPlugin.onInvocationStart(
+ new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
+ customPlugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
assertTrue(
exporter.getFinishedSpanItems().stream()
@@ -1497,9 +1601,15 @@ void workflowSpanName_isConfigurable() {
@Test
void failedInvocation_setsErrorOnBothWorkflowAndInvocationSpans() {
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now(), Map.of(), Map.of()));
plugin.onInvocationEnd(new InvocationEndInfo(
- "req-1", "arn:exec1", true, InvocationStatus.FAILED, new RuntimeException("boom")));
+ "req-1",
+ "arn:exec1",
+ true,
+ Instant.now(),
+ Map.of(),
+ InvocationStatus.FAILED,
+ new RuntimeException("boom")));
var workflowSpan = spanByName("Workflow");
var invocationSpan = spanByName("Invocation");
diff --git a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/MdcSpanEnricherTest.java b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/MdcSpanEnricherTest.java
index 2318e4075..708441015 100644
--- a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/MdcSpanEnricherTest.java
+++ b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/MdcSpanEnricherTest.java
@@ -8,6 +8,7 @@
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
import java.time.Instant;
+import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
@@ -63,10 +64,11 @@ void plugin_withMdcEnabled_setsFieldsInMdc() {
.enableMdc(true)
.build());
- plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec-mdc-test", true, Instant.now()));
+ plugin.onInvocationStart(
+ new InvocationInfo("req-1", "arn:exec-mdc-test", true, Instant.now(), Map.of(), Map.of()));
plugin.onUserFunctionStart(
- new UserFunctionStartInfo("op-1", "step", "STEP", "Step", null, Instant.now(), false, 1));
+ new UserFunctionStartInfo("op-1", "step", "STEP", "Step", null, Instant.now(), false, false, 1));
// MDC should have trace fields after onUserFunctionStart
assertNotNull(MDC.get(MdcSpanEnricher.MDC_TRACE_ID));
@@ -74,15 +76,15 @@ void plugin_withMdcEnabled_setsFieldsInMdc() {
assertNotNull(MDC.get(MdcSpanEnricher.MDC_TRACE_SAMPLED));
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
- "op-1", "step", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
+ "op-1", "step", "STEP", "Step", null, Instant.now(), Instant.now(), false, false, 1, true, null));
// After onUserFunctionEnd: span_id is cleared, but trace_id remains for handler-level logs between steps
assertNotNull(MDC.get(MdcSpanEnricher.MDC_TRACE_ID), "trace_id should persist between steps");
assertNull(MDC.get(MdcSpanEnricher.MDC_SPAN_ID), "span_id should be cleared after step");
assertNotNull(MDC.get(MdcSpanEnricher.MDC_TRACE_SAMPLED), "trace_flags should persist between steps");
- plugin.onInvocationEnd(
- new InvocationEndInfo("req-1", "arn:exec-mdc-test", true, InvocationStatus.SUCCEEDED, null));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec-mdc-test", true, Instant.now(), Map.of(), InvocationStatus.SUCCEEDED, null));
// After onInvocationEnd: all MDC fields are cleared
assertNull(MDC.get(MdcSpanEnricher.MDC_TRACE_ID));
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java b/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java
index 6ab6bdbc3..617ed4639 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/execution/DurableExecutor.java
@@ -28,6 +28,7 @@
import software.amazon.lambda.durable.plugin.InvocationEndInfo;
import software.amazon.lambda.durable.plugin.InvocationInfo;
import software.amazon.lambda.durable.plugin.InvocationStatus;
+import software.amazon.lambda.durable.plugin.PluginInfoConverter;
import software.amazon.lambda.durable.plugin.PluginRunner;
import software.amazon.lambda.durable.serde.SerDes;
import software.amazon.lambda.durable.util.ExceptionHelper;
@@ -67,11 +68,19 @@ public static DurableExecutionOutput execute(
// onInvocationStart runs on the user thread so plugins can
// inject ThreadLocal objects, update MDC, etc.
// executionStartTime comes from the initial EXECUTION operation in the first backend event.
+ // The operation maps are snapshots of the state delivered for this invocation; on a replay
+ // invocation updatedOperations names the operations the backend completed while suspended.
pluginRunner.onInvocationStart(new InvocationInfo(
requestId,
executionArn,
isFirstInvocation,
- executionManager.getExecutionOperation().startTimestamp()));
+ executionManager.getExecutionOperation().startTimestamp(),
+ PluginInfoConverter.toOperationItemMap(
+ executionManager.getOperationsSnapshot(),
+ executionManager.getInitialOperationIds()),
+ PluginInfoConverter.toOperationItemMap(
+ executionManager.getUpdatedOperationsSnapshot(),
+ executionManager.getInitialOperationIds())));
var userInput = extractUserInput(
executionManager.getExecutionOperation(), config.getSerDes(), inputType);
@@ -99,6 +108,7 @@ public static DurableExecutionOutput execute(
if (cause instanceof SuspendExecutionException) {
fireOnInvocationEnd(
pluginRunner,
+ executionManager,
requestId,
executionArn,
isFirstInvocation,
@@ -115,6 +125,7 @@ public static DurableExecutionOutput execute(
&& unrecoverableDurableExecutionException.isRetryable()) {
fireOnInvocationEnd(
pluginRunner,
+ executionManager,
requestId,
executionArn,
isFirstInvocation,
@@ -127,6 +138,7 @@ public static DurableExecutionOutput execute(
logger.debug("Execution failed: {}", cause.getMessage());
fireOnInvocationEnd(
pluginRunner,
+ executionManager,
requestId,
executionArn,
isFirstInvocation,
@@ -141,6 +153,7 @@ public static DurableExecutionOutput execute(
DurableExecutionOutput.success(handleLargePayload(executionManager, outputPayload));
fireOnInvocationEnd(
pluginRunner,
+ executionManager,
requestId,
executionArn,
isFirstInvocation,
@@ -159,12 +172,24 @@ public static DurableExecutionOutput execute(
private static void fireOnInvocationEnd(
PluginRunner pluginRunner,
+ ExecutionManager executionManager,
String requestId,
String executionArn,
boolean isFirstInvocation,
InvocationStatus status,
Throwable error) {
- pluginRunner.onInvocationEnd(new InvocationEndInfo(requestId, executionArn, isFirstInvocation, status, error));
+ // The end info repeats the start info's identity surface (execution start time, operation snapshot) so an
+ // invocation-end hook never has to correlate back to the start hook. The snapshot is taken at end time, so
+ // unlike the start info it also contains operations created during this invocation.
+ pluginRunner.onInvocationEnd(new InvocationEndInfo(
+ requestId,
+ executionArn,
+ isFirstInvocation,
+ executionManager.getExecutionOperation().startTimestamp(),
+ PluginInfoConverter.toOperationItemMap(
+ executionManager.getOperationsSnapshot(), executionManager.getInitialOperationIds()),
+ status,
+ error));
}
private static String handleLargePayload(ExecutionManager executionManager, String outputPayload) {
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/execution/ExecutionManager.java b/sdk/src/main/java/software/amazon/lambda/durable/execution/ExecutionManager.java
index 1c45cb0d6..b52216d99 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/execution/ExecutionManager.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/execution/ExecutionManager.java
@@ -5,6 +5,7 @@
import com.amazonaws.services.lambda.runtime.Context;
import java.time.Instant;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
@@ -63,6 +64,7 @@ public class ExecutionManager implements SafeCloseable {
private final AtomicReference executionMode;
private final DurableConfig durableConfig;
private final Set updatedOperationIdsSinceLastInvocation;
+ private final Set initialOperationIds;
// ===== Thread Coordination =====
private final Map registeredOperations = new ConcurrentHashMap<>();
@@ -89,6 +91,11 @@ public ExecutionManager(DurableExecutionInput input, DurableConfig config, Conte
this.operationStorage = checkpointManager.fetchAllPages(input.initialExecutionState()).stream()
.collect(Collectors.toConcurrentMap(Operation::id, op -> op));
+ // The ids delivered in this invocation's initial state. Everything else in operationStorage is created during
+ // this invocation, so this set is what distinguishes replayed operations from freshly-started ones for the
+ // plugin hooks' isReplay indicators.
+ this.initialOperationIds = Set.copyOf(operationStorage.keySet());
+
// Start in REPLAY mode if we have more than just the initial EXECUTION operation
this.executionMode =
new AtomicReference<>(operationStorage.size() > 1 ? ExecutionMode.REPLAY : ExecutionMode.EXECUTION);
@@ -132,6 +139,47 @@ public boolean isOperationUpdatedSinceLastInvocation(String operationId) {
return updatedOperationIdsSinceLastInvocation.contains(operationId);
}
+ /**
+ * Returns {@code true} if the given operation was present in the checkpointed state delivered at the start of this
+ * invocation, i.e. it predates this invocation and is being replayed rather than started fresh. Unlike
+ * {@link #getOperationAndUpdateReplayState(String)} this does not mutate the execution's replay mode, so it is safe
+ * to call from plugin-hook firing sites.
+ *
+ * @param operationId the operation ID to check
+ * @return true if the operation was delivered in this invocation's initial state
+ */
+ public boolean wasObservedAtInvocationStart(String operationId) {
+ return initialOperationIds.contains(operationId);
+ }
+
+ /** Returns the ids of the operations delivered in this invocation's initial state. */
+ public Set getInitialOperationIds() {
+ return initialOperationIds;
+ }
+
+ /**
+ * Returns an immutable snapshot of the operations currently tracked for this execution, including the initial
+ * EXECUTION operation. Non-mutating; intended for the invocation-level plugin hooks.
+ *
+ * @return a snapshot of the tracked operations
+ */
+ public Collection getOperationsSnapshot() {
+ return List.copyOf(operationStorage.values());
+ }
+
+ /**
+ * Returns the subset of {@link #getOperationsSnapshot()} whose ids the backend reported as updated since the last
+ * successful invocation. Empty on the first invocation. Ids without a corresponding tracked operation are skipped.
+ *
+ * @return a snapshot of the externally-updated operations
+ */
+ public Collection getUpdatedOperationsSnapshot() {
+ return updatedOperationIdsSinceLastInvocation.stream()
+ .map(operationStorage::get)
+ .filter(Objects::nonNull)
+ .toList();
+ }
+
/** Registers an operation so it can receive checkpoint completion notifications. */
public void registerOperation(BaseDurableOperation operation) {
registeredOperations.put(operation.getOperationId(), operation);
@@ -162,7 +210,11 @@ private void onCheckpointComplete(List newOperations) {
durableConfig
.getPluginRunner()
.onOperationChange(PluginInfoConverter.toOperationChangeInfo(
- requestId, durableExecutionArn, updatedOperations, operationStorage.values()));
+ requestId,
+ durableExecutionArn,
+ updatedOperations,
+ operationStorage.values(),
+ initialOperationIds));
}
}
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/operation/BaseDurableOperation.java b/sdk/src/main/java/software/amazon/lambda/durable/operation/BaseDurableOperation.java
index f0391567a..9a6e92771 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/operation/BaseDurableOperation.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/operation/BaseDurableOperation.java
@@ -350,7 +350,11 @@ protected void runUserHandler(Runnable runnable, ThreadType threadType) {
protected T runUserFunction(Integer attempt, Supplier userFunction) {
var pluginRunner = getPluginRunner();
var startInfo = PluginInfoConverter.toUserFunctionStartInfo(
- operationIdentifier, durableContext.getParentId(), durableContext.isReplaying(), attempt);
+ operationIdentifier,
+ durableContext.getParentId(),
+ executionManager.wasObservedAtInvocationStart(getOperationId()),
+ durableContext.isReplaying(),
+ attempt);
pluginRunner.onUserFunctionStart(startInfo);
try {
T result = userFunction.get();
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java
index ba85b9e70..3e48ec061 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationEndInfo.java
@@ -2,12 +2,23 @@
// SPDX-License-Identifier: Apache-2.0
package software.amazon.lambda.durable.plugin;
+import java.time.Instant;
+import java.util.Map;
+
/**
* Information provided at the end of a Lambda invocation.
*
+ * Carries the same invocation-identity surface as {@link InvocationInfo} so an invocation-end hook never has to
+ * correlate back to the start hook to learn the execution start time or the operation state.
+ *
* @param requestId the Lambda request ID for this invocation
* @param durableExecutionArn the durable execution ARN
* @param isFirstInvocation true if this is the first invocation of the execution
+ * @param executionStartTime the start timestamp of the durable execution, taken from the initial EXECUTION operation in
+ * the first event delivered by the backend. Stable across all invocations of the same execution.
+ * @param operations a snapshot of the checkpointed operations known when the invocation ended, keyed by operation ID.
+ * Unlike {@link InvocationInfo#operations()} this includes operations created during this invocation. Includes the
+ * initial EXECUTION operation. Empty-but-never-null.
* @param invocationStatus the invocation outcome (SUCCEEDED, FAILED, or PENDING)
* @param executionError non-null if the execution failed
* @deprecated This is a preview API that is experimental and may be changed or removed in future releases.
@@ -17,5 +28,7 @@ public record InvocationEndInfo(
String requestId,
String durableExecutionArn,
boolean isFirstInvocation,
+ Instant executionStartTime,
+ Map operations,
InvocationStatus invocationStatus,
Throwable executionError) {}
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java
index 8e2406829..9fbb38c22 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/plugin/InvocationInfo.java
@@ -3,6 +3,7 @@
package software.amazon.lambda.durable.plugin;
import java.time.Instant;
+import java.util.Map;
/**
* Invocation-level information available to plugin hooks.
@@ -12,8 +13,19 @@
* @param isFirstInvocation true if this is the first invocation of the execution (not a replay invocation)
* @param executionStartTime the start timestamp of the durable execution, taken from the initial EXECUTION operation in
* the first event delivered by the backend. Stable across all invocations of the same execution.
+ * @param operations a snapshot of the checkpointed operations delivered at the start of this invocation, keyed by
+ * operation ID. Includes the initial EXECUTION operation. Empty-but-never-null.
+ * @param updatedOperations the subset of {@code operations} that changed externally between the previous invocation and
+ * this one (a wait timer expired, a callback was received, a chained invoke completed), keyed by operation ID.
+ * Sourced from the {@code UpdatedOperationIds} field of the durable invocation input, so it is empty on the first
+ * invocation. Empty-but-never-null.
* @deprecated This is a preview API that is experimental and may be changed or removed in future releases.
*/
@Deprecated
public record InvocationInfo(
- String requestId, String durableExecutionArn, boolean isFirstInvocation, Instant executionStartTime) {}
+ String requestId,
+ String durableExecutionArn,
+ boolean isFirstInvocation,
+ Instant executionStartTime,
+ Map operations,
+ Map updatedOperations) {}
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/plugin/OperationChangeItemInfo.java b/sdk/src/main/java/software/amazon/lambda/durable/plugin/OperationChangeItemInfo.java
index 6846280fe..c33de47a3 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/plugin/OperationChangeItemInfo.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/plugin/OperationChangeItemInfo.java
@@ -6,7 +6,11 @@
import software.amazon.awssdk.services.lambda.model.OperationStatus;
/**
- * Operation-level information for a single operation within an {@link OperationChangeInfo}.
+ * Operation-level information for a single operation within an {@link OperationChangeInfo}, and the snapshot record
+ * used for the operation maps carried on {@link InvocationInfo} / {@link InvocationEndInfo}.
+ *
+ * Carries the full operation field surface, mirroring {@link OperationEndInfo}, so a plugin observing an operation
+ * through a change delta or an invocation-level map sees the same fields it would see through the per-operation hooks.
*
* @param id operation ID
* @param name human-readable operation name (may be null)
@@ -15,8 +19,11 @@
* @param parentId parent operation ID (null for root-level operations)
* @param startTimestamp when the operation started
* @param endTimestamp when the operation ended
- * @param error non-null if the operation failed
* @param status operation status
+ * @param attempt the attempt number for retriable operations (STEP, WAIT_FOR_CONDITION) — null for others
+ * @param isReplay true if this operation was already present in the checkpointed state delivered at the start of the
+ * current invocation (i.e. it predates this invocation) rather than being created during it
+ * @param error non-null if the operation failed
* @deprecated This is a preview API that is experimental and may be changed or removed in future releases.
*/
@Deprecated
@@ -28,5 +35,7 @@ public record OperationChangeItemInfo(
String parentId,
Instant startTimestamp,
Instant endTimestamp,
- Throwable error,
- OperationStatus status) {}
+ OperationStatus status,
+ Integer attempt,
+ boolean isReplay,
+ Throwable error) {}
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/plugin/PluginInfoConverter.java b/sdk/src/main/java/software/amazon/lambda/durable/plugin/PluginInfoConverter.java
index 58911f7be..57433415d 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/plugin/PluginInfoConverter.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/plugin/PluginInfoConverter.java
@@ -4,6 +4,8 @@
import java.time.Instant;
import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
import java.util.stream.Collectors;
import software.amazon.awssdk.services.lambda.model.Operation;
import software.amazon.lambda.durable.model.OperationIdentifier;
@@ -77,12 +79,17 @@ public static OperationEndInfo toOperationEndInfo(
*
* @param identifier the operation identifier containing id, name, type, and subType
* @param parentId the parent operation ID (may be null)
- * @param isReplay true if the user function is called during replay (context operations)
+ * @param isReplay true if this operation was already present in the checkpointed state when it started
+ * @param isReplayingChildren true if the child operations of this context body are replaying from checkpoints
* @param attempt the 1-based attempt number (null for context operations)
* @return a UserFunctionStartInfo record
*/
public static UserFunctionStartInfo toUserFunctionStartInfo(
- OperationIdentifier identifier, String parentId, boolean isReplayingChildren, Integer attempt) {
+ OperationIdentifier identifier,
+ String parentId,
+ boolean isReplay,
+ boolean isReplayingChildren,
+ Integer attempt) {
return new UserFunctionStartInfo(
identifier.operationId(),
identifier.name(),
@@ -90,6 +97,7 @@ public static UserFunctionStartInfo toUserFunctionStartInfo(
identifier.subType() != null ? identifier.subType().getValue() : null,
parentId,
Instant.now(),
+ isReplay,
isReplayingChildren,
attempt);
}
@@ -112,6 +120,7 @@ public static UserFunctionEndInfo toUserFunctionEndInfo(
startInfo.parentId(),
startInfo.startTimestamp(),
Instant.now(),
+ startInfo.isReplay(),
startInfo.isReplayingChildren(),
startInfo.attempt(),
succeeded,
@@ -126,25 +135,41 @@ public static UserFunctionEndInfo toUserFunctionEndInfo(
* @param durableExecutionArn the durable execution ARN
* @param updatedOperations the durable operations whose status changed in this checkpoint response
* @param allOperations all durable operations tracked for the execution after this response
+ * @param replayedOperationIds ids of the operations delivered in this invocation's initial state, used to populate
+ * each item's {@code isReplay} indicator
* @return an OperationChangeInfo record
*/
public static OperationChangeInfo toOperationChangeInfo(
String requestId,
String durableExecutionArn,
Collection updatedOperations,
- Collection allOperations) {
+ Collection allOperations,
+ Set replayedOperationIds) {
return new OperationChangeInfo(
requestId,
durableExecutionArn,
- updatedOperations.stream()
- .collect(Collectors.toUnmodifiableMap(
- Operation::id, PluginInfoConverter::toOperationChangeItemInfo)),
- allOperations.stream()
- .collect(Collectors.toUnmodifiableMap(
- Operation::id, PluginInfoConverter::toOperationChangeItemInfo)));
+ toOperationItemMap(updatedOperations, replayedOperationIds),
+ toOperationItemMap(allOperations, replayedOperationIds));
}
- private static OperationChangeItemInfo toOperationChangeItemInfo(Operation operation) {
+ /**
+ * Converts durable operations to an unmodifiable map of {@link OperationChangeItemInfo}, keyed by operation ID.
+ *
+ * @param operations the durable operations to convert
+ * @param replayedOperationIds ids of the operations delivered in this invocation's initial state, used to populate
+ * each item's {@code isReplay} indicator
+ * @return an unmodifiable map of operation ID to item info
+ */
+ public static Map toOperationItemMap(
+ Collection operations, Set replayedOperationIds) {
+ return operations.stream()
+ .collect(Collectors.toUnmodifiableMap(
+ Operation::id,
+ operation ->
+ toOperationChangeItemInfo(operation, replayedOperationIds.contains(operation.id()))));
+ }
+
+ private static OperationChangeItemInfo toOperationChangeItemInfo(Operation operation, boolean isReplay) {
return new OperationChangeItemInfo(
operation.id(),
operation.name(),
@@ -153,7 +178,9 @@ private static OperationChangeItemInfo toOperationChangeItemInfo(Operation opera
operation.parentId(),
operation.startTimestamp(),
operation.endTimestamp(),
- BaseDurableOperation.extractErrorFromOperation(operation),
- operation.status());
+ operation.status(),
+ operation.stepDetails() != null ? operation.stepDetails().attempt() : null,
+ isReplay,
+ BaseDurableOperation.extractErrorFromOperation(operation));
}
}
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/plugin/UserFunctionEndInfo.java b/sdk/src/main/java/software/amazon/lambda/durable/plugin/UserFunctionEndInfo.java
index 86136b3bd..63b8b8ca1 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/plugin/UserFunctionEndInfo.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/plugin/UserFunctionEndInfo.java
@@ -16,6 +16,9 @@
* @param parentId parent operation ID (null for root-level operations)
* @param startTimestamp when the user function started
* @param endTimestamp when the user function ended
+ * @param isReplay true if THIS operation was already present in the execution's checkpointed state when it started
+ * (i.e. observed via replay rather than created fresh in this invocation). Distinct from
+ * {@code isReplayingChildren}, which is about the child operations of a context body.
* @param isReplayingChildren true if child operations within this context are being replayed from checkpoints
* @param attempt 1-based attempt number for steps/waitForCondition, null for context operations
* @param succeeded true if the user function completed without error
@@ -31,6 +34,7 @@ public record UserFunctionEndInfo(
String parentId,
Instant startTimestamp,
Instant endTimestamp,
+ boolean isReplay,
boolean isReplayingChildren,
Integer attempt,
boolean succeeded,
diff --git a/sdk/src/main/java/software/amazon/lambda/durable/plugin/UserFunctionStartInfo.java b/sdk/src/main/java/software/amazon/lambda/durable/plugin/UserFunctionStartInfo.java
index 842f82536..4285a5026 100644
--- a/sdk/src/main/java/software/amazon/lambda/durable/plugin/UserFunctionStartInfo.java
+++ b/sdk/src/main/java/software/amazon/lambda/durable/plugin/UserFunctionStartInfo.java
@@ -16,6 +16,9 @@
* @param subType operation sub-type (Map, Parallel, WaitForCondition, etc.) — may be null
* @param parentId parent operation ID (null for root-level operations)
* @param startTimestamp when the user function started
+ * @param isReplay true if THIS operation was already present in the execution's checkpointed state when it started
+ * (i.e. observed via replay rather than created fresh in this invocation). Distinct from
+ * {@code isReplayingChildren}, which is about the child operations of a context body.
* @param isReplayingChildren true if child operations within this context are being replayed from checkpoints
* @param attempt 1-based attempt number for steps/waitForCondition, null for context operations
* @deprecated This is a preview API that is experimental and may be changed or removed in future releases.
@@ -28,5 +31,6 @@ public record UserFunctionStartInfo(
String subType,
String parentId,
Instant startTimestamp,
+ boolean isReplay,
boolean isReplayingChildren,
Integer attempt) {}
diff --git a/sdk/src/test/java/software/amazon/lambda/durable/DurableConfigTest.java b/sdk/src/test/java/software/amazon/lambda/durable/DurableConfigTest.java
index c0bd54147..266e43a6e 100644
--- a/sdk/src/test/java/software/amazon/lambda/durable/DurableConfigTest.java
+++ b/sdk/src/test/java/software/amazon/lambda/durable/DurableConfigTest.java
@@ -547,7 +547,7 @@ void testBuilder_WithMultiplePlugins_AllRegistered() {
config.getPluginRunner()
.onInvocationStart(new software.amazon.lambda.durable.plugin.InvocationInfo(
- "req-1", "arn:test", true, java.time.Instant.now()));
+ "req-1", "arn:test", true, java.time.Instant.now(), java.util.Map.of(), java.util.Map.of()));
assertEquals(List.of("p1:onInvocationStart", "p2:onInvocationStart"), calls);
}
@@ -567,7 +567,7 @@ void testBuilder_WithPlugins_CalledMultipleTimes_Replaces() {
config.getPluginRunner()
.onInvocationStart(new software.amazon.lambda.durable.plugin.InvocationInfo(
- "req-1", "arn:test", true, java.time.Instant.now()));
+ "req-1", "arn:test", true, java.time.Instant.now(), java.util.Map.of(), java.util.Map.of()));
assertEquals(List.of("p2:onInvocationStart", "p3:onInvocationStart"), calls);
}
diff --git a/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginInfoConverterTest.java b/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginInfoConverterTest.java
index dbba0870f..82724788f 100644
--- a/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginInfoConverterTest.java
+++ b/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginInfoConverterTest.java
@@ -100,7 +100,7 @@ void toOperationEndInfo_nullError_forSuccess() {
@Test
void toUserFunctionStartInfo_stepAttempt() {
- var info = PluginInfoConverter.toUserFunctionStartInfo(STEP_IDENTIFIER, PARENT_ID, false, 3);
+ var info = PluginInfoConverter.toUserFunctionStartInfo(STEP_IDENTIFIER, PARENT_ID, true, false, 3);
assertEquals(OPERATION_ID, info.id());
assertEquals(OPERATION_NAME, info.name());
@@ -108,16 +108,18 @@ void toUserFunctionStartInfo_stepAttempt() {
assertEquals("Step", info.subType());
assertEquals(PARENT_ID, info.parentId());
assertNotNull(info.startTimestamp());
+ assertTrue(info.isReplay());
assertFalse(info.isReplayingChildren());
assertEquals(3, info.attempt());
}
@Test
void toUserFunctionStartInfo_contextOperation() {
- var info = PluginInfoConverter.toUserFunctionStartInfo(MAP_IDENTIFIER, PARENT_ID, true, null);
+ var info = PluginInfoConverter.toUserFunctionStartInfo(MAP_IDENTIFIER, PARENT_ID, false, true, null);
assertEquals("CONTEXT", info.type());
assertEquals("Map", info.subType());
+ assertFalse(info.isReplay());
assertTrue(info.isReplayingChildren());
assertNull(info.attempt());
}
@@ -126,7 +128,7 @@ void toUserFunctionStartInfo_contextOperation() {
@Test
void toUserFunctionEndInfo_succeeded() {
- var startInfo = PluginInfoConverter.toUserFunctionStartInfo(STEP_IDENTIFIER, PARENT_ID, false, 1);
+ var startInfo = PluginInfoConverter.toUserFunctionStartInfo(STEP_IDENTIFIER, PARENT_ID, true, false, 1);
var endInfo = PluginInfoConverter.toUserFunctionEndInfo(startInfo, true, null);
@@ -134,6 +136,7 @@ void toUserFunctionEndInfo_succeeded() {
assertEquals(OPERATION_NAME, endInfo.name());
assertEquals(startInfo.startTimestamp(), endInfo.startTimestamp());
assertNotNull(endInfo.endTimestamp());
+ assertTrue(endInfo.isReplay());
assertFalse(endInfo.isReplayingChildren());
assertEquals(1, endInfo.attempt());
assertTrue(endInfo.succeeded());
@@ -143,7 +146,7 @@ void toUserFunctionEndInfo_succeeded() {
@Test
void toUserFunctionEndInfo_failed() {
var error = new RuntimeException("step failed");
- var startInfo = PluginInfoConverter.toUserFunctionStartInfo(STEP_IDENTIFIER, null, false, 2);
+ var startInfo = PluginInfoConverter.toUserFunctionStartInfo(STEP_IDENTIFIER, null, false, false, 2);
var endInfo = PluginInfoConverter.toUserFunctionEndInfo(startInfo, false, error);
diff --git a/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginRunnerTest.java b/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginRunnerTest.java
index d34b5a8ca..1f287e737 100644
--- a/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginRunnerTest.java
+++ b/sdk/src/test/java/software/amazon/lambda/durable/plugin/PluginRunnerTest.java
@@ -137,12 +137,23 @@ void pluginRunner_isImmutable() {
private static InvocationInfo invocationInfo() {
return new InvocationInfo(
- "req-123", "arn:aws:lambda:us-east-1:123456789012:function:test", false, Instant.now());
+ "req-123",
+ "arn:aws:lambda:us-east-1:123456789012:function:test",
+ false,
+ Instant.now(),
+ Map.of(),
+ Map.of());
}
private static InvocationEndInfo invocationEndInfo() {
return new InvocationEndInfo(
- "req-123", "arn:aws:lambda:us-east-1:123456789012:function:test", false, null, null);
+ "req-123",
+ "arn:aws:lambda:us-east-1:123456789012:function:test",
+ false,
+ Instant.now(),
+ Map.of(),
+ null,
+ null);
}
private static OperationInfo operationInfo() {
@@ -160,12 +171,12 @@ private static OperationChangeInfo operationChangeInfo() {
}
private static UserFunctionStartInfo attemptInfo() {
- return new UserFunctionStartInfo("op-1", "test-step", "STEP", null, null, Instant.now(), false, 1);
+ return new UserFunctionStartInfo("op-1", "test-step", "STEP", null, null, Instant.now(), false, false, 1);
}
private static UserFunctionEndInfo attemptEndInfo() {
return new UserFunctionEndInfo(
- "op-1", "test-step", "STEP", null, null, Instant.now(), Instant.now(), false, 1, true, null);
+ "op-1", "test-step", "STEP", null, null, Instant.now(), Instant.now(), false, false, 1, true, null);
}
// ─── Test plugin implementations ─────────────────────────────────────