Skip to content
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Durable Execution Telemetry**: the bundled Athena table (the CDK Glue table and the
telemetry-core Athena DDL) now declares `emittedat`, `starttime`, `endtime`, and the operation
`starttime`/`endtime` as `timestamp` instead of `string`. Athena compared the strings as text,
so a filter such as `starttime >= '2026-10-23T18:42:46Z'` left out every record from that
second. Queries now compare these columns with `TIMESTAMP` literals, and the Athena schema
prompt says so. A table created earlier keeps its `string` columns: recreate it, or run
`ALTER TABLE <table> REPLACE COLUMNS (...)` with the new definition.
- **Durable Execution Telemetry MCP**: `list_executions` rewrites `since` and `until` to the
stored timestamp format (UTC, 3 fraction digits, `Z`) before it compares them. Before, a bound
was compared as given. On DynamoDB and CloudWatch Logs, a bound without a fraction or with an
offset such as `-07:00` was compared as text and returned the wrong records. A bound with no
offset is read as UTC. On those two destinations the comparison is still text, so records from
the Python exporter `0.0.1` preview, which wrote microsecond timestamps, can be ordered
incorrectly within one second.
- **Durable Execution Telemetry**: every error in a record (the execution `error`,
`operations[].error`, and `operationsByName.<name>.error`) now carries its cause chain as
`{ name, message, cause? }`, in that key order. `cause` follows `Error.cause`, including the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,16 +250,16 @@ export function buildCreateTableDdl(opts: {
return `CREATE EXTERNAL TABLE IF NOT EXISTS \`${opts.database}\`.\`${opts.table}\` (
recordtype string,
schemaversion string,
emittedat string,
emittedat timestamp,
executionarn string,
executionname string,
functionname string,
functionqualifier string,
region string,
accountid string,
status string,
starttime string,
endtime string,
starttime timestamp,
endtime timestamp,
durationms bigint,
input string,
output string,
Expand All @@ -271,8 +271,8 @@ export function buildCreateTableDdl(opts: {
subtype:string,
parentid:string,
status:string,
starttime:string,
endtime:string,
starttime:timestamp,
endtime:timestamp,
durationms:bigint,
attempt:int,
error:struct<name:string,message:string>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -469,23 +469,24 @@ a Glue table "TABLE_NAME" and Hive-partitioned by year/month/day (partition colu
below). Columns:
- recordType: string ("DurableExecutionTelemetry")
- schemaVersion: string
- emittedAt: string (ISO-8601)
- emittedAt: timestamp (UTC)
- executionArn: string
- executionName: string (nullable)
- functionName: string
- functionQualifier: string
- region: string
- accountId: string
- status: string — RUNNING | SUCCEEDED | FAILED
- startTime: string (ISO-8601)
- endTime: string (ISO-8601, nullable)
- startTime: timestamp (UTC)
- endTime: timestamp (UTC, nullable)
- durationMs: bigint (nullable)
- input: string (JSON-serialized; use json_extract_scalar/json_extract to read fields — see lowercasing note below)
- output: string (JSON-serialized; use json_extract_scalar/json_extract to read fields — see lowercasing note below)
- error: struct<name:string,message:string> (nullable)
- operations: array<struct<id,name,type,subType,parentId,status,startTime,endTime,durationMs,attempt,error,result,truncated>>
This is the canonical per-operation array (NOT operationsByName — S3Exporter
keeps the raw array). To filter/aggregate by operation name or fields, UNNEST it.
The operation startTime/endTime are timestamp (UTC) too.
- year, month, day: string — Hive partition columns (from the S3 key
year=YYYY/month=MM/day=DD/), zero-padded 2-digit strings for month/day. Filter
on these instead of parsing startTime/emittedAt for partition pruning.
Expand All @@ -512,6 +513,7 @@ const DIALECT_ATHENA = `Target query language: Trino/Presto SQL (Amazon Athena)
Rules:
- Table name is TABLE_NAME (already database-qualified by the connection — do not prefix it).
- To inspect operations, UNNEST the array and give the element its own alias, then reference THAT alias directly — NOT prefixed by the table alias. Correct: FROM TABLE_NAME t CROSS JOIN UNNEST(t.operations) AS u(op) ... then use op.name, op.type, op.status, op.durationMs, op.error, etc. WRONG: t.op.name (the unnested element op is its own alias, not a column of the table alias t; writing t.op.name fails with COLUMN_NOT_FOUND). Use a distinct alias for the unnest table (e.g. u) so it does not collide with the table alias.
- emittedAt, startTime, endTime (and op.startTime/op.endTime) are native timestamp columns holding UTC. Compare them with TIMESTAMP literals or timestamp expressions, never with strings: WHERE starttime >= TIMESTAMP '2026-10-23 18:00:00' or WHERE starttime >= current_timestamp - INTERVAL '1' HOUR. A string such as '2026-10-23T18:00:00Z' fails with a type mismatch. Use date_diff('millisecond', starttime, endtime) for elapsed time and date_trunc('hour', starttime) for bucketing. Keep the year/month/day partition filter as well, so the query scans only the matching partitions.
- json_extract_scalar(col, '$.path') returns a scalar string; json_extract(col, '$.path') returns JSON — cast as needed, e.g. CAST(json_extract_scalar(...) AS double).
- Every path segment passed to json_extract_scalar/json_extract on input or output MUST be lowercase (the SerDe lowercases JSON keys on read) — e.g. '$.claimtype' not '$.claimType'.
- A field that lives inside input/output (e.g. claimType, decision, amount) is NEVER a bare column — it does not exist as a plain identifier anywhere in this table, including in GROUP BY, ORDER BY, and WHERE. Referencing it bare (e.g. "GROUP BY claimType" or "GROUP BY claim_type") fails with COLUMN_NOT_FOUND. Always wrap it in json_extract_scalar(input, '$.path') / json_extract_scalar(output, '$.path') — and repeat that full expression everywhere it's used (SELECT, GROUP BY, ORDER BY, WHERE), not just an alias, since GROUP BY/ORDER BY must reference the actual computed expression.
Expand Down
25 changes: 21 additions & 4 deletions packages/aws-durable-execution-sdk-js-telemetry-exporter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ interface DurableExecutionTelemetryRecord {

// Execution state
status: "RUNNING" | "SUCCEEDED" | "FAILED"; // suspends (waits/timers) surface as RUNNING
startTime: string; // ISO-8601
endTime?: string; // ISO-8601 (absent while running)
startTime: string; // UTC, YYYY-MM-DDTHH:MM:SS.sssZ (see Timestamps)
endTime?: string; // Same format (absent while running)
durationMs?: number; // Total duration (absent while running)

// Payload
Expand All @@ -62,8 +62,8 @@ interface OperationRecord {
subType?: string; // Additional categorization
parentId?: string; // Parent operation ID (for child contexts)
status: string; // STARTED | SUCCEEDED | FAILED | PENDING | CANCELLED
startTime?: string; // ISO-8601
endTime?: string; // ISO-8601
startTime?: string; // UTC, YYYY-MM-DDTHH:MM:SS.sssZ (see Timestamps)
endTime?: string; // Same format
durationMs?: number; // Operation duration
attempt?: number; // Retry attempt count
error?: ErrorRecord; // Per-operation error
Expand All @@ -77,6 +77,23 @@ interface ErrorRecord {
}
```

## Timestamps

Every timestamp in a record (`emittedAt`, `startTime`, `endTime`, and each
operation's `startTime` and `endTime`) has one format in every SDK:
`YYYY-MM-DDTHH:MM:SS.sssZ`. It is UTC, it has exactly 3 fraction digits, and it
ends with a literal `Z`, for example `2026-10-23T18:42:40.243Z`. This is the
output of `Date.prototype.toISOString()`.

DynamoDB, CloudWatch Logs Insights, and other text columns compare these values
as text. Text order equals time order only when every value has the same width
and zone. So the format is fixed, and a value is never shortened to drop a zero
fraction.

Destinations with a time type store the value in it. Aurora and Redshift write
`TIMESTAMPTZ` columns, OpenSearch maps the fields as `date`, and the bundled
Athena table declares them as `timestamp`.

## Error names and causes

Every error in a record — the execution `error`, each `operations[].error`, and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,16 +308,16 @@ export class TelemetryDestinationsStack extends cdk.Stack {
columns: [
{ name: "recordtype", type: "string" },
{ name: "schemaversion", type: "string" },
{ name: "emittedat", type: "string" },
{ name: "emittedat", type: "timestamp" },
{ name: "executionarn", type: "string" },
{ name: "executionname", type: "string" },
{ name: "functionname", type: "string" },
{ name: "functionqualifier", type: "string" },
{ name: "region", type: "string" },
{ name: "accountid", type: "string" },
{ name: "status", type: "string" },
{ name: "starttime", type: "string" },
{ name: "endtime", type: "string" },
{ name: "starttime", type: "timestamp" },
{ name: "endtime", type: "timestamp" },
{ name: "durationms", type: "bigint" },
{ name: "input", type: "string" },
{ name: "output", type: "string" },
Expand All @@ -337,7 +337,7 @@ export class TelemetryDestinationsStack extends cdk.Stack {
// field names, but keeping both DDL definitions in the same
// casing avoids them drifting into visually different text
// describing an identical schema.
type: "array<struct<id:string,name:string,type:string,subtype:string,parentid:string,status:string,starttime:string,endtime:string,durationms:bigint,attempt:int,error:struct<name:string,message:string>,result:string,truncated:boolean>>",
type: "array<struct<id:string,name:string,type:string,subtype:string,parentid:string,status:string,starttime:timestamp,endtime:timestamp,durationms:bigint,attempt:int,error:struct<name:string,message:string>,result:string,truncated:boolean>>",
},
{ name: "truncated", type: "boolean" },
{ name: "droppedcauses", type: "boolean" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,10 @@ The server registers five tools. Call them roughly in this order.
query language required. **Prefer this over a hand-written `query`:** it cannot
be got wrong and costs fewer tokens. Reach for `query` only when a question
cannot be expressed through these filters. On log destinations the scanned window
follows `since`, so filtering to last week scans last week.
follows `since`, so filtering to last week scans last week. `since` and `until`
accept an ISO-8601 date or date-time. A value with no offset is read as UTC.
Each bound is rewritten to the stored format (UTC, 3 fraction digits, `Z`)
before it is compared.
- **`get_execution`** — Fetch a single execution record by its execution ARN. A
record that does not exist is a success with `found: false`, not an error. For
the Athena/`s3` destination you may also pass `year`/`month`/`day` to prune to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,10 @@ describe("list_executions — validated filters build the expected per-dialect S
expect(sql).toContain("recordtype = 'DurableExecutionTelemetry'");
expect(sql).toContain("status = 'FAILED'");
expect(sql).toContain("functionname = 'my-fn'");
expect(sql).toContain("starttime >= '2024-01-01'");
expect(sql).toContain("starttime <= '2024-02-01T00:00:00Z'");
// Athena's time columns are `timestamp`, so bounds are TIMESTAMP literals
// in UTC, rewritten to the stored millisecond precision.
expect(sql).toContain("starttime >= TIMESTAMP '2024-01-01 00:00:00.000'");
expect(sql).toContain("starttime <= TIMESTAMP '2024-02-01 00:00:00.000'");
expect(sql).toMatch(/ORDER BY starttime DESC LIMIT 100$/);
});

Expand All @@ -315,7 +317,7 @@ describe("list_executions — validated filters build the expected per-dialect S
expect(sql).toContain("FROM durable_execution_telemetry");
expect(sql).toContain("status = 'FAILED'");
expect(sql).toContain("function_name = 'my-fn'");
expect(sql).toContain("start_time >= '2024-01-01'");
expect(sql).toContain("start_time >= '2024-01-01T00:00:00.000Z'");
expect(sql).toMatch(/ORDER BY start_time DESC LIMIT 100$/);
});

Expand Down
89 changes: 89 additions & 0 deletions packages/aws-durable-execution-sdk-js-telemetry-mcp/src/sqlSafe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,95 @@ export function validateTimestamp(label: string, value: string): string {
);
}

/**
* The parts of an ISO-8601 bound that {@link ISO_TIMESTAMP} accepted. A date
* alone has no time parts; a value with no offset has no zone.
*/
const ISO_PARTS =
/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d+))?(Z|[+-]\d{2}:?\d{2})?)?$/;

/**
* Validate a `since`/`until` bound, then rewrite it in the one format every SDK
* writes record timestamps in: UTC, exactly 3 fraction digits, a literal `Z`
* (`2024-01-31T12:34:56.000Z`, the output of `Date.prototype.toISOString()`).
*
* The rewrite is needed because some destinations compare these values as text
* (DynamoDB, CloudWatch Logs Insights). Text order equals time order only when
* both sides have the same width and the same zone. So a bound such as
* `2024-01-31T12:34:56Z` or `2024-01-31T05:34:56-07:00` is rewritten to the
* stored form before it is compared.
*
* Rules, in order:
* 1. The value must match {@link ISO_TIMESTAMP} and name a real calendar date
* and time. `Date.parse` rolls an impossible date forward (`2026-02-30`
* becomes `2026-03-02`), so the day, hour, minute and second are checked
* first, and an impossible value throws.
* 2. A value with no offset is read as UTC, because every record timestamp is
* UTC. A date alone means midnight UTC.
* 3. `precision: "exact"` keeps every fraction digit the caller gave (at least
* 3). Use it for typed time columns (Athena `timestamp`, PostgreSQL and
* Redshift `timestamptz`, OpenSearch `date`). They compare instants, not
* text, and an existing table can hold values finer than a millisecond, so
* the bound must not move.
* 4. `precision: "millisecond"` gives exactly 3 fraction digits. Use it where
* the comparison is on text (DynamoDB, Logs Insights), because text order
* equals time order only at one fixed width. A bound with finer digits is
* rounded without changing which millisecond-precision records it selects.
* A lower bound (`round: "up"`) moves up to the next millisecond, so a
* record stored before the requested instant stays excluded. An upper bound
* (`round: "down"`) is cut to its millisecond, so a record stored at or
* before the requested instant stays included.
*
* The output contains only digits, `-`, `:`, `.`, `T` and `Z`, so it is safe to
* interpolate.
*/
export function normalizeTimestamp(
label: string,
value: string,
round: "up" | "down",
precision: "millisecond" | "exact",
): string {
const validated = validateTimestamp(label, value);
const parts = ISO_PARTS.exec(validated);
const invalid = (): Error =>
new Error(
`Invalid ${label} "${value}". It matches the ISO-8601 shape but is not ` +
`a real date or time.`,
);
if (!parts) throw invalid();
const [, y, mo, d, h = "00", mi = "00", sec = "00", frac = "", zone] = parts;
const year = Number(y);
const month = Number(mo);
const day = Number(d);
// Day 0 of the next month is the last day of this month, leap years included.
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
if (
month < 1 ||
month > 12 ||
day < 1 ||
day > daysInMonth ||
Number(h) > 23 ||
Number(mi) > 59 ||
Number(sec) > 59
) {
throw invalid();
}

const millis = (frac + "000").slice(0, 3);
const finer = /[1-9]/.test(frac.slice(3));
// `+0700` is valid ISO-8601 but Date.parse rejects it; insert the colon.
const offset = (zone ?? "Z").replace(/^([+-]\d{2})(\d{2})$/, "$1:$2");
const ms = Date.parse(`${y}-${mo}-${d}T${h}:${mi}:${sec}.${millis}${offset}`);
Comment thread
ParidelPooya marked this conversation as resolved.
if (Number.isNaN(ms)) throw invalid();
if (precision === "exact") {
// An offset moves whole minutes, so the digits past the millisecond are
// the same in UTC. Trailing zeros carry no information and are dropped.
const extra = frac.slice(3).replace(/0+$/, "");
return new Date(ms).toISOString().replace("Z", `${extra}Z`);
}
return new Date(round === "up" && finer ? ms + 1 : ms).toISOString();
Comment thread
ParidelPooya marked this conversation as resolved.
Comment thread
ParidelPooya marked this conversation as resolved.
}

/**
* An Athena Hive partition component (`year`/`month`/`day`). These are digit
* strings by definition (the Glue table projects them as `integer`), so a
Expand Down
Loading
Loading