Monitoring durable functions used to feel like a puzzle. A workflow that waits two days for a book to be shipped is spread across several Lambda invocations, and every invocation writes its own log lines. Answering “Which executions got stuck in the order-copy step yesterday?” took real effort.
Since October 28, 2026, there is the Durable Execution Telemetry Exporter for exactly this. It turns every execution into a single record and sends it to a destination of your choice, for example CloudWatch Logs or DynamoDB. I took a closer look because it fills the gap that bothered me most after writing my article about Lambda durable functions. In this article you build a small interlibrary loan workflow for a city library step by step, redact personal data, deploy everything with the AWS CDK and finally query the data in plain English through an MCP server.
In short
The telemetry exporter hooks into the Durable Execution SDK as a plugin. For each execution it builds a complete JSON record with status, steps, duration and errors and sends it to up to 13 destinations. The plugin itself costs nothing extra; you only pay for the destination. The biggest pitfall: executions that Lambda stops or times out on its own do not show up unless you add them via EventBridge.
What you will learn in this article
- How the telemetry exporter builds records and when it sends them
- Redacting input and output before they leave the function
- Testing the exporter locally, without an AWS account, using your own in-memory exporter
- Backfilling STOPPED and TIMED_OUT with an EventBridge rule
- Querying telemetry with Claude Code and the MCP server
Why monitoring durable functions is harder
A durable function is a Lambda function that saves its progress in checkpoints. A checkpoint is a stored intermediate result: once a step has finished, Lambda does not run it again on the next invocation but reads the result from storage instead. When the workflow waits, Lambda ends the invocation and starts a new one later. This resuming is called replay.
For business logic that is great. For monitoring, however, it means that one business execution consists of many technical invocations. Classic Lambda metrics such as duration or error rate describe the single invocation, not the workflow. In addition, Lambda keeps the execution history only for the configured retention period, at most 90 days. So if you want to ask later how long loans take on average, you need the data in a place of your own.
That is exactly what the exporter takes care of. It is therefore worth looking at how it works before we write code.
What the telemetry exporter does
The exporter is a plugin that runs in the same process as your handler. It hooks into the SDK lifecycle but does not sit in the request path. On every invocation it builds a cumulative snapshot of the entire execution from the checkpoints, so it is not a delta. A later record therefore always replaces an earlier one.
AWS offers the plugin for TypeScript, Python, Java and Go. According to the announcement, it comes at no additional charge and is available in all Regions that support durable functions. Costs only arise at the destination, such as log ingestion or DynamoDB writes.
One record instead of a hundred log lines
Here is a record from our example (shortened). All fields come from the package’s DurableExecutionTelemetryRecord type:
{
"recordType": "DurableExecutionTelemetry",
"schemaVersion": "1.0",
"executionArn": "arn:aws:lambda:eu-central-1:111122223333:function:LoanWorkflow:live/durable-execution/…",
"functionName": "LoanWorkflow",
"functionQualifier": "live",
"status": "SUCCEEDED",
"startTime": "2026-10-29T08:00:00.000Z",
"endTime": "2026-10-31T08:00:02.410Z",
"input": { "requestId": "FL-1001", "isbn": "9783161484100" },
"output": { "status": "READY_FOR_PICKUP", "partnerLibrary": "North City Library" },
"operations": [
{ "name": "find-partner-library", "type": "STEP", "status": "SUCCEEDED", "durationMs": 41, "result": "North City Library" },
{ "name": "order-copy", "type": "STEP", "status": "SUCCEEDED", "durationMs": 63 },
{ "name": "shipping", "type": "WAIT", "status": "SUCCEEDED" },
{ "name": "notify-reader", "type": "STEP", "status": "SUCCEEDED", "durationMs": 88 }
]
}
The operations field is the interesting part. Every step and every wait appears there with status and duration. Results of individual steps are left out by default unless you explicitly allow them. That is a good default, because step results often contain exactly the data that does not belong in monitoring.
When a record is created
With emitMode you decide how often the exporter sends. Your choice directly affects cost and freshness:
| emitMode | When it sends | Good for |
|---|---|---|
on-complete (default) |
once on SUCCEEDED or FAILED | analysis, dashboards |
on-failure |
only on FAILED | error analysis, lowest overhead |
on-change |
on every status change, including RUNNING | live progress, highest load |
On top of that there is samplingRate between 0 and 1. The decision is based on a hash of the execution ARN. An execution is therefore either recorded completely or not at all, never halfway. Each of the 13 destinations has its own class, from LambdaLogExporter (the function’s own log, no extra IAM permissions) through S3Exporter for Athena to OTelExporter for any OpenTelemetry backend. You can combine several destinations, and the exporter serves them in parallel.
The path of a telemetry record: from the plugin inside the function to querying it via MCP.
Step by step: monitoring durable functions for an interlibrary loan
Our example: a reader orders a book that her own library does not have. The workflow looks for a partner library, orders a copy there, waits for shipping and notifies the reader. The workflow needs her name and email address, but they have no business in monitoring.
Prerequisites
- Node.js 22 or later (the exporter requires at least 22)
- AWS CLI v2 with credentials for a test account
- A basic understanding of durable functions, for example from my introductory article
- About 30 minutes
Step 1: Create the project
Create the project and install the SDK, the exporter and the CDK. You only need the testing package locally:
mkdir library-loan && cd library-loan
npm init -y
npm pkg set type=module
npm install @aws/durable-execution-sdk-js @aws/durable-execution-sdk-js-telemetry-exporter \
@aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb aws-cdk-lib constructs
npm install -D typescript @types/node @types/aws-lambda tsx esbuild aws-cdk \
@aws/durable-execution-sdk-js-testing
mkdir src lib bin test
For tests to run outside Lambda, you also need the optional peer dependencies. At startup the exporter loads every AWS client, including the ones you do not use. In Lambda the Node.js runtime provides them; locally it does not:
npm install -D @aws-sdk/client-s3 @aws-sdk/util-dynamodb @aws-sdk/client-rds-data \
@aws-sdk/client-cloudwatch-logs @aws-sdk/client-firehose @aws-sdk/client-eventbridge \
@aws-sdk/client-redshift-data @aws-sdk/client-sqs @aws-sdk/credential-provider-node \
"@smithy/signature-v4@^4" @aws-crypto/sha256-js
The tsconfig.json is deliberately strict. The cdk.json starts the app with tsx:
{
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src", "lib", "bin", "test"]
}
{
"app": "npx tsx bin/app.ts"
}
Step 2: Configure telemetry and redact data
The configuration lives in its own file. Two small functions decide what is left of input and output. I also allow the result of a single step, because it only contains the partner library’s name and helps with troubleshooting:
// src/telemetry.ts
import {
DynamoDBExporter,
LambdaLogExporter,
durableExecutionTelemetry,
type TelemetryExporter,
} from "@aws/durable-execution-sdk-js-telemetry-exporter";
export interface LoanRequest {
requestId: string;
isbn: string;
readerName: string;
readerEmail: string;
}
export interface LoanResult {
status: "READY_FOR_PICKUP" | "NOT_AVAILABLE";
pickupCode?: string;
partnerLibrary?: string;
}
// Only fields needed for troubleshooting – no personal data.
export function redactInput(input: unknown): { requestId?: string; isbn?: string } {
const req = (input ?? {}) as Partial<LoanRequest>;
return { requestId: req.requestId, isbn: req.isbn };
}
// Nobody needs the pickup code in monitoring.
export function redactOutput(output: unknown): { status?: string; partnerLibrary?: string } {
const res = (output ?? {}) as Partial<LoanResult>;
return { status: res.status, partnerLibrary: res.partnerLibrary };
}
export function parseSamplingRate(raw: string | undefined): number {
const value = Number(raw ?? "1");
if (Number.isNaN(value)) return 1;
return Math.min(1, Math.max(0, value));
}
export function defaultExporters(): TelemetryExporter[] {
const exporters: TelemetryExporter[] = [new LambdaLogExporter()];
const tableName = process.env.TELEMETRY_TABLE;
if (tableName) {
exporters.push(new DynamoDBExporter({ tableName }));
}
return exporters;
}
export function createTelemetry(exporters: TelemetryExporter[]) {
return durableExecutionTelemetry({
emitMode: "on-complete",
samplingRate: parseSamplingRate(process.env.TELEMETRY_SAMPLING_RATE),
exporters,
content: {
input: redactInput,
output: redactOutput,
operations: {
includeErrors: true,
overrides: [
// Deliberately export this step's result (it only contains the library name)
{ operationName: "find-partner-library", result: (r) => r },
],
},
},
});
}
Two details matter. First, you create the plugin once at module scope, not inside the handler. Second, the sampling rate does not throw on nonsense like “7” but gets clamped by my helper. The exporter itself would also correct invalid values and log a warning.
Step 3: Write the workflow
The workflow consists of three steps and one wait. The business logic is intentionally just a placeholder. To make it testable, the file also exports a factory function that accepts any exporters:
// src/loan-workflow.ts
import {
type DurableContext,
withDurableExecution,
} from "@aws/durable-execution-sdk-js";
import type { TelemetryExporter } from "@aws/durable-execution-sdk-js-telemetry-exporter";
import {
createTelemetry,
defaultExporters,
type LoanRequest,
type LoanResult,
} from "./telemetry.js";
// Placeholders for real business logic (catalog, partner library, email)
async function findPartnerLibrary(isbn: string): Promise<string | null> {
return isbn.startsWith("978") ? "North City Library" : null;
}
async function orderCopy(library: string, requestId: string): Promise<string> {
return `${library.slice(0, 3).toUpperCase()}-${requestId}`;
}
async function notifyReader(email: string, pickupCode: string): Promise<void> {
// This is where the email with the pickup code would be sent – the log only shows the masked address.
console.log(JSON.stringify({ msg: "reader notified", to: email.replace(/.+@/, "***@"), codeLength: pickupCode.length }));
}
async function loanWorkflow(
event: LoanRequest,
context: DurableContext,
): Promise<LoanResult> {
const library = await context.step("find-partner-library", async () =>
findPartnerLibrary(event.isbn),
);
if (!library) {
return { status: "NOT_AVAILABLE" };
}
const pickupCode = await context.step("order-copy", async () =>
orderCopy(library, event.requestId),
);
// Shipping between libraries: the function sleeps without using compute time.
await context.wait("shipping", { seconds: Number(process.env.SHIPPING_SECONDS ?? "172800") });
await context.step("notify-reader", async () =>
notifyReader(event.readerEmail, pickupCode),
);
return { status: "READY_FOR_PICKUP", pickupCode, partnerLibrary: library };
}
// Factory so tests can plug in their own exporters
export function createHandler(exporters: TelemetryExporter[]) {
return withDurableExecution(loanWorkflow, {
plugins: [createTelemetry(exporters)],
});
}
export const handler = createHandler(defaultExporters());
Pay attention to the operation names. They end up unchanged as keys in the records. In CloudWatch Logs Insights and OpenSearch a dot in a name works as a path separator, which is why I only use lowercase letters and hyphens.
Step 4: Test locally, without an AWS account
The testing package simulates the checkpoint backend in-process. Instead of a real destination, I plug in an exporter that just collects records in an array. The TelemetryExporter interface needs exactly one method for that:
// test/telemetry.test.ts
import { after, before, test } from "node:test";
import assert from "node:assert/strict";
import { LocalDurableTestRunner } from "@aws/durable-execution-sdk-js-testing";
import type {
DurableExecutionTelemetryRecord,
TelemetryExporter,
} from "@aws/durable-execution-sdk-js-telemetry-exporter";
import { parseSamplingRate } from "../src/telemetry.js";
process.env.SHIPPING_SECONDS = "1";
const { createHandler } = await import("../src/loan-workflow.js");
// Collects all records in memory instead of sending them
class MemoryExporter implements TelemetryExporter {
records: DurableExecutionTelemetryRecord[] = [];
async export(record: DurableExecutionTelemetryRecord): Promise<void> {
this.records.push(record);
}
}
before(() => LocalDurableTestRunner.setupTestEnvironment({ skipTime: false }));
after(() => LocalDurableTestRunner.teardownTestEnvironment());
test("exports one redacted record per completed execution", async () => {
const memory = new MemoryExporter();
const runner = new LocalDurableTestRunner({ handlerFunction: createHandler([memory]) });
const execution = await runner.run({
payload: {
requestId: "FL-1001",
isbn: "9783161484100",
readerName: "Jane Doe",
readerEmail: "jane@example.org",
},
});
assert.equal(execution.getStatus(), "SUCCEEDED");
assert.equal(memory.records.length, 1);
const record = memory.records[0];
assert.equal(record.status, "SUCCEEDED");
assert.deepEqual(record.input, { requestId: "FL-1001", isbn: "9783161484100" });
assert.deepEqual(record.output, { status: "READY_FOR_PICKUP", partnerLibrary: "North City Library" });
const json = JSON.stringify(record);
assert.ok(!json.includes("jane@example.org"), "email must not be exported");
assert.ok(!json.includes("NOR-FL-1001"), "pickup code must not be exported");
const names = record.operations.map((op) => op.name);
assert.deepEqual(names, ["find-partner-library", "order-copy", "shipping", "notify-reader"]);
const partner = record.operations.find((op) => op.name === "find-partner-library");
assert.equal(partner?.result, "North City Library");
});
test("parseSamplingRate clamps invalid values", () => {
assert.equal(parseSamplingRate(undefined), 1);
assert.equal(parseSamplingRate("0.1"), 0.1);
assert.equal(parseSamplingRate("7"), 1);
assert.equal(parseSamplingRate("abc"), 1);
});
Add the script "test": "tsx --test test/*.test.ts" to your package.json and run the tests. The wait is set to one second in the test:
npx tsc --noEmit
npm test
Expected result:
{"msg":"reader notified","to":"***@example.org","codeLength":11}
✔ exports one redacted record per completed execution
✔ parseSamplingRate clamps invalid values
ℹ tests 2
ℹ pass 2
ℹ fail 0
The first test is the real win. It makes sure that neither the email address nor the pickup code ever lands in monitoring. If someone changes the redaction later, the test fails before any data reaches the cloud.
Step 5: Deploy with the AWS CDK
The stack contains the telemetry table, the workflow with durableConfig, an alias and the function that will close the gap later. For functions with durableConfig, the CDK automatically attaches the managed policy AWSLambdaBasicDurableExecutionRolePolicy; you grant write access to the table yourself:
// lib/loan-stack.ts
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from "aws-cdk-lib";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as events from "aws-cdk-lib/aws-events";
import * as targets from "aws-cdk-lib/aws-events-targets";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import * as logs from "aws-cdk-lib/aws-logs";
import type { Construct } from "constructs";
const here = path.dirname(fileURLToPath(import.meta.url));
export class LoanStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Target for the DynamoDBExporter: pk = execution ARN, sk = timestamp
const telemetryTable = new dynamodb.TableV2(this, "TelemetryTable", {
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
billing: dynamodb.Billing.onDemand(),
removalPolicy: RemovalPolicy.DESTROY, // demo only
});
const workflow = new NodejsFunction(this, "LoanWorkflow", {
entry: path.join(here, "../src/loan-workflow.ts"),
handler: "handler",
runtime: lambda.Runtime.NODEJS_22_X,
architecture: lambda.Architecture.ARM_64,
timeout: Duration.minutes(1),
memorySize: 512,
durableConfig: {
executionTimeout: Duration.days(7),
retentionPeriod: Duration.days(14),
},
environment: {
TELEMETRY_TABLE: telemetryTable.tableName,
TELEMETRY_SAMPLING_RATE: "1",
SHIPPING_SECONDS: "60",
},
logGroup: new logs.LogGroup(this, "LoanWorkflowLogs", {
retention: logs.RetentionDays.ONE_MONTH,
removalPolicy: RemovalPolicy.DESTROY,
}),
});
telemetryTable.grantWriteData(workflow);
// Durable functions need a qualified ARN (version or alias)
const live = new lambda.Alias(this, "LiveAlias", {
aliasName: "live",
version: workflow.currentVersion,
});
// Close the gap: only Lambda itself reports STOPPED and TIMED_OUT
const closeGaps = new NodejsFunction(this, "CloseGaps", {
entry: path.join(here, "../src/close-gaps.ts"),
handler: "handler",
runtime: lambda.Runtime.NODEJS_22_X,
architecture: lambda.Architecture.ARM_64,
environment: { TELEMETRY_TABLE: telemetryTable.tableName },
});
telemetryTable.grantWriteData(closeGaps);
new events.Rule(this, "StoppedOrTimedOut", {
eventPattern: {
source: ["aws.lambda"],
detailType: ["Durable Execution Status Change"],
detail: { status: ["STOPPED", "TIMED_OUT"] },
},
targets: [new targets.LambdaFunction(closeGaps)],
});
new CfnOutput(this, "WorkflowAliasArn", { value: live.functionArn });
new CfnOutput(this, "TelemetryTableName", { value: telemetryTable.tableName });
}
}
// bin/app.ts
import { App } from "aws-cdk-lib";
import { LoanStack } from "../lib/loan-stack.js";
const app = new App();
new LoanStack(app, "LibraryLoanStack");
When bundling, esbuild leaves out the @aws-sdk/* packages because the runtime ships them. Check, bootstrap and deploy:
npx tsc --noEmit
npx cdk bootstrap # only once per account and region
npx cdk deploy
After the deployment the console shows two outputs: WorkflowAliasArn and TelemetryTableName. You will need both in a moment.
Step 6: Run it and look at the records
You always invoke durable functions through a qualified ARN, meaning a version or an alias. For long workflows, use the asynchronous invocation:
aws lambda invoke \
--function-name "<WorkflowAliasArn from the output>" \
--invocation-type Event \
--cli-binary-format raw-in-base64-out \
--payload '{"requestId":"FL-1001","isbn":"9783161484100","readerName":"Jane Doe","readerEmail":"jane@example.org"}' \
response.json
In the stack the shipping time is set to 60 seconds. After a little over a minute the record shows up in the function’s log. In CloudWatch Logs Insights this query finds all telemetry records:
fields executionName, status, durationMs, input.requestId
| filter recordType = "DurableExecutionTelemetry"
| sort @timestamp desc
| limit 20
At the same time, the same record sits in DynamoDB, with the execution ARN as pk and the emit time as sk:
aws dynamodb scan \
--table-name "<TelemetryTableName from the output>" \
--projection-expression "pk, sk, #s" \
--expression-attribute-names '{"#s":"status"}' \
--max-items 5
🎬 Interlibrary loan flow with telemetry – this animated diagram only runs on my site: watch it on sascha-ms.de
Closing the gap for STOPPED and TIMED_OUT
One point is somewhat hidden in the AWS blog post: the plugin only sends when the function is invoked. If you stop an execution through the API or the execution timeout expires, Lambda sets the final status itself without calling your code. Telemetry then keeps the last state, which with on-complete means nothing at all.
Lambda reports every status change as an event on the EventBridge default bus, with the source aws.lambda. The EventBridge rule in the stack filters for the two critical statuses and calls a small function that writes a substitute record into the same table:
// src/close-gaps.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import type { EventBridgeEvent } from "aws-lambda";
interface StatusChangeDetail {
durableExecutionArn: string;
durableExecutionName?: string;
functionArn: string;
status: string;
startTimestamp: string;
endTimestamp?: string;
}
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
// Writes STOPPED and TIMED_OUT into the same table as the exporter.
export const handler = async (
event: EventBridgeEvent<string, StatusChangeDetail>,
): Promise<void> => {
const d = event.detail;
await db.send(
new PutCommand({
TableName: process.env.TELEMETRY_TABLE,
Item: {
pk: d.durableExecutionArn,
sk: d.endTimestamp ?? event.time,
recordType: "DurableExecutionTelemetry",
source: "eventbridge-gap-filler",
executionArn: d.durableExecutionArn,
executionName: d.durableExecutionName,
functionArn: d.functionArn,
status: d.status,
startTime: d.startTimestamp,
endTime: d.endTimestamp,
},
}),
);
};
A detail you notice while reading: the Lambda documentation calls the event type Durable Execution Status Change, while the announcement blog post uses a different spelling. I went with the Lambda documentation. After deploying, trigger a timeout once and check that the rule fires. Also check that the ARN in the event matches the telemetry’s executionArn, so both entries end up under the same pk.
Monitoring durable functions with AI: the MCP server
Together with the exporter, AWS released an MCP server. MCP (Model Context Protocol) is an open standard that lets AI assistants call tools. The server runs locally via npx, reads its configuration only from environment variables and uses the regular AWS credential chain. This is how you add it to Claude Code:
claude mcp add durable-telemetry \
--scope project \
--env DURABLE_TELEMETRY_DESTINATION_TYPE=dynamodb \
--env DURABLE_TELEMETRY_DYNAMODB_TABLE_NAME=<TelemetryTableName> \
--env DURABLE_TELEMETRY_REGION=eu-central-1 \
-- npx -y @aws/durable-execution-sdk-js-telemetry-mcp
After that, five tools are available: test_destination checks the connection, describe_schema returns the schema, list_executions and get_execution filter without a query language, and query handles everything else. The assistant can then answer questions like “Which loans failed this week, and in which step?” straight from the table.
The server takes security seriously. SQL destinations accept only SELECT queries, and before the first query it asks for confirmation, because it cannot prove that your credentials are read-only. You should make that true by giving the server its own role with read permissions only. For DynamoDB, this policy is enough:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"dynamodb:DescribeTable",
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:Scan",
"dynamodb:PartiQLSelect"
],
"Resource": "arn:aws:dynamodb:eu-central-1:111122223333:table/<TelemetryTableName>"
}
]
}
If you want to build an MCP server yourself, you will find the basics in my guide to MCP servers with TypeScript.
Costs and data protection
The plugin is free; the destinations are not. With on-complete you write one record per execution, with on-change considerably more. As a rough estimate, multiply the number of executions by the number of status changes. With many executions, a lower sampling rate and on-failure for less important workflows help.
On data protection my recommendation is clear: redact too much rather than too little. By default the exporter sends input and output in full. That is convenient, but the wrong starting point for workflows with customer data. Keep in mind, too, that your telemetry destination now holds a copy of the data. The DynamoDB table therefore needs a retention rule, for example a cleanup job or a custom exporter that sets a TTL attribute. I show how to add alarms and budgets in Lambda in Monitoring Lambda with CDK.
Common mistakes
- ERR_MODULE_NOT_FOUND for @aws-sdk/client-s3: The peer dependencies are missing locally. Install the complete list from step 1, even if you do not use S3.
- No records for stopped executions: That is by design. No invocation, no plugin, hence the EventBridge rule.
-
Step results are missing: Results are excluded by default. Allow them per operation via
content.operations.overrides. - Encrypted results in the record: Transforms receive the stored, serialized result. If a step uses custom Serdes, you will not see plain text there.
-
Incomplete tree for map and parallel: After a resume,
full-treeonly shows child operations withpluginsConfig.childOperationsDepth, which costs extra replays. - Slow invocations under load: Exports run one after another. A slow exporter delays every execution that ends at the same time, not just one.
My take
I consider the exporter the most important addition since durable functions were released. Until now, the candid answer to “How do I monitor this?” was something like “with patience”. Now there is a well-designed data model, sensible defaults and a clean interface for plugging in your own destinations. I particularly like that sampling decides per execution and never records half a workflow.
I see two things critically. The STOPPED and TIMED_OUT gap is documented but easy to miss, and those are exactly the cases you want to see in operations. Second, the default configuration sends all input and output. For an example that is handy; for production I would wish for the opposite. The MCP server, on the other hand, strikes me as a good example of AI helping in operations without doing harm: read-only, with a confirmation prompt and clear limits.
Do you still need Step Functions for visibility? For workflows that business teams should follow graphically, the Step Functions console keeps its edge. For teams that prefer writing their flows in TypeScript, visibility is no longer an argument against durable functions.
FAQ
Does the telemetry exporter for durable functions cost extra?
No. According to AWS, the plugin is available at no additional charge. You only pay for the destination, such as CloudWatch Logs ingestion, DynamoDB writes or S3 storage.
Which destinations does the exporter support?
There are 13 built-in destinations, including CloudWatch Logs, S3, DynamoDB, Aurora, Redshift, OpenSearch, EventBridge, Firehose, SQS, OpenTelemetry, HTTPS endpoints and files. You build your own destinations with the TelemetryExporter interface.
Do I need additional IAM permissions?
Not for the default LambdaLogExporter, because the function is already allowed to write its own log. For every other destination, the function needs write permissions on that destination.
Does the exporter work with Python too?
Yes. The plugin exists for TypeScript, Python, Java and Go, each in the repository of the matching Durable Execution SDK.
Your next step
Take an existing durable function, attach the plugin with the LambdaLogExporter and write the test from step 4 for your redaction first. It takes half an hour, and afterwards you will see your workflows as a whole for the first time. I am curious: which question would you ask your telemetry first?
Sources
- AWS What’s New: Lambda durable functions now exports live telemetry (Oct 28, 2026)
- AWS Compute Blog: Observing AWS Lambda durable functions with the Durable Execution Telemetry Exporter
- AWS docs: Durable Execution Telemetry Exporter
- AWS docs: Durable Execution Telemetry MCP server
- AWS docs: Telemetry overview
- AWS docs: Monitoring durable functions (EventBridge events)
- AWS docs: Deploy and invoke Lambda durable functions with the AWS CLI
- GitHub: aws/aws-durable-execution-sdk-js
This post was created with AI assistance.
👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)