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); <...
