Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 153 additions & 2 deletions packages/aws-durable-execution-sdk-python-telemetry-exporter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,132 @@ def handler(event, context):
```

With no exporter configured, records are written to the function's own
CloudWatch log group as single JSON lines (the `LambdaLogExporter` default),
carrying the name-keyed `operationsByName` summary. The `S3Exporter` writes the
CloudWatch log group (the `LambdaLogExporter` default), one Lambda JSON log
line per record whose `message` is the compact record JSON carrying the
name-keyed `operationsByName` summary (see
[LambdaLogExporter](#lambdalogexporter)). The `S3Exporter` writes the
lossless per-occurrence `operations` array, one object per execution
(upsert-by-execution-name, so re-emission overwrites rather than appends).

## Record fields shared with the JS exporter

Records follow the JS exporter's shape, which the record readers rely on for
every SDK:

- `error.name`: see [Error names](#error-names) below.
- An opted-in operation `result` (`operations[].result`,
`operationsByName.<name>.result`, and the value passed to an override's
`result` transform) is the plain JSON value, for example `{"amount": 99.99}`.
Results checkpointed by the SDK's default serializer as a `{"t": ..., "v":
...}` envelope are decoded with that serializer and converted to JSON values:
`datetime`/`date` become an ISO-8601 string from `isoformat()`, `UUID` a
string, `bytes` a base64 string, `tuple` a list, and a map/parallel
`BatchResult` its `to_dict()` form. A `Decimal` keeps its numeric value, but
not its scale or trailing zeros (`Decimal("1.10")` is `1.1`, `Decimal("1E+2")`
is `100`): an integral one of at most 300 digits is a JSON integer, any other
is a JSON number only when that float converts back to exactly the same
`Decimal`, and otherwise the string `str(value)` (for example
`"12345678901234567.89"`, or `"1E+5000"`). A non-finite `Decimal`
or `float` (NaN, Infinity) becomes `null`, so the record is strict JSON. A
payload that is not an envelope is passed through as parsed JSON (non-finite
numbers again become `null`), or as the raw string when it is not JSON; an
envelope that cannot be decoded is passed through as parsed JSON.
- The plugin cannot tell which serializer wrote a result, because the hooks do
not carry it, so an envelope is recognised by its shape alone: a JSON object
with exactly the keys `t` and `v`, where `t` is a string. A result written by
`JsonSerDes`, a custom `SerDes` (which the plugin never runs) or a chained
invoke that has this exact shape, and whose `t` is one of the default
serializer's type tags, is decoded as if the default serializer wrote it. For
example `{"t": "s", "v": "x"}` is reported as `"x"`, `{"t": "b", "v": 0}` as
`false`, and `{"t": "l", "v": [...]}` as the decoded list. A shape-matching
object whose `t` is not a default tag, or whose `v` does not decode, is
passed through unchanged. Avoid a root object of exactly `{"t", "v"}` in
results that you opt in when they do not use the default serializer.
- Every record built at invocation end carries `endTime` (the emission time)
and `durationMs` (`endTime - startTime`), including the `RUNNING` record of
an invocation that suspends or retries (`on-change` mode). Records built at
invocation start or on an operation change carry neither. Do not use the
presence of `endTime` as a completion signal; use `status`.

## Error names

The rule for the `error.name` field is the same in the JS, Python and Java
exporters. `error.message` is never changed by this rule. The rule applies to
the execution-level `error`, every `operations[].error`, and every
`operationsByName.<name>.error`.

1. A record names the error that caused the failure. A wrapper error that the
SDK added is never the name, except in the bounded cases of rules 3 and 9.
2. The SDK unwraps an error only when the error's type is one of the SDK's own
wrapper types. A user error is never unwrapped, even if it has a cause.
3. Unwrapping follows the chain of SDK wrappers. The depth counts unwrap steps,
one per wrapper passed, not levels in the operation tree. At most 16 unwraps
happen. If the error reached after 16 unwraps is still a wrapper, its name
is reported. If the chain reaches an error it has already visited, the walk
stops and the last error visited before the repeat is reported.
4. If the chain ends at a user error, the name is that error's type name.
5. If the chain ends at an SDK error that is not a wrapper, the name is that SDK
error's simple class name. A callback timeout is an example.
6. A type name that is a qualified string is reduced to its last segment after
any `.` or `$`. If that segment is empty, the string is kept as it is. A
missing type, or a type made only of blank characters, is `Error`. The blank
characters are the six ASCII whitespace characters: space, `\t`, `\n`,
`\r`, `\f` and `\v`. No other character counts as blank, and names are
never trimmed.
7. A failure gets the same name whether the record is built live or from a
replayed checkpoint.
8. When the execution fails because an operation's error propagated, the
execution `error.name` equals that operation's `error.name`.
9. If a checkpoint holds only a wrapper type, which an older SDK can write, the
name is that wrapper's name. The exporter never guesses a cause from other
operations.

A callback that is failed externally with an explicit `ErrorType` is named
after that type, after the reduction of rule 6. This includes the type
`"Error"`, which reports `Error`. Only a missing or blank type reports the SDK
callback-failure class.

User error types have the same name in every SDK, because the user names them.
SDK error types keep each language's own class name. For example, a callback
timeout is `CallbackTimeoutError` in JS and Python and
`CallbackTimeoutException` in Java. Every SDK name for a callback timeout
contains `Timeout`. An invoke timeout does not always: Java names it
`InvokeTimedOutException`.

How Python applies the rule:

- The core SDK does the unwrapping (rules 2 to 5) when it checkpoints an
error: a failed step, child context, map item, parallel branch,
`wait_for_callback` context and the execution itself record the type of the
error that a wrapper wraps, not the wrapper. The SDK's wrapper types are
`StepError`, `ChildContextError` (also used for map items and parallel
branches), `InvokeError`, `WaitForConditionError`, `CallbackSubmitterError`,
`CallbackExternalError` and the base `DurableOperationError`. Every other
type is never unwrapped: user errors (including a user subclass of a
wrapper), and SDK errors such as `CallbackTimeoutError`, `SerDesError`,
`ValidationError` and `BatchCompletionError`.
- The exporter names each failed operation by its own `ErrorType`, reduced by
rule 6, and the execution by the execution's own `ErrorType`, reduced. It
does not match errors by message, stack trace or position in the tree. The
depth of the operation tree does not matter: a step in 18 nested child
contexts is `PaymentError` at every level.
- When two map items or parallel branches fail with different types and
`throw_if_error()` raises, each item or branch keeps its own name, and the
execution is named after the error that `throw_if_error()` raised.
- A checkpoint written by an older SDK can hold a wrapper type such as
`aws_durable_execution_sdk_python.exceptions.StepError`. Its name is then the
wrapper's simple class name, for example `StepError` (rule 9).
- A callback that times out is `CallbackTimeoutError` on the callback
operation (the service type is `Callback.Timeout`), on the `wait_for_callback`
context, and on the execution. A callback that an external system fails with
an error type is named by that type's simple name, for example
`PaymentError` for `com.acme.PaymentError`, and `Error` for `Error`. With
no error type, or a blank one, it is `CallbackExternalError`, the class the
SDK raises. A `wait_for_callback` submitter that fails with an empty message
is reported with the SDK's message `Callback submitter failed` on the
execution and is still named after the submitter's error.
- A user error class with no explicit name is named by its class name.

## Exporters

All exporters live in `aws_durable_execution_sdk_python_telemetry_exporter.exporters` and
Expand Down Expand Up @@ -77,6 +198,36 @@ the Lambda runtime's boto3 otherwise; none adds a required dependency.
size limit; omitting it keeps the default. `HttpExporter` and `FileExporter`
have no default and do not truncate unless a limit is set.

### LambdaLogExporter

Prints one line per record to stdout, so it lands in the function's own log
group with no extra IAM. The line is the Lambda JSON log envelope, with keys
in this order:

```json
{"timestamp":"2026-01-01T00:00:00.000Z","level":"INFO","message":"{\"recordType\":\"DurableExecutionTelemetry\",...}"}
```

`timestamp` is the UTC write time with millisecond precision, `level` is
always `INFO`, and `message` is the compact record JSON as a string. This is
the shape the Node.js runtime gives the JS exporter's `console.log` line in the
JSON log format, and record readers for this destination filter on
`level = "INFO" and message like /DurableExecutionTelemetry/` and parse
`message`. Lambda captures Python `print` output as written in both the Text
and JSON log formats, and does not re-encode lines that are already JSON, so
the envelope is never wrapped twice. With the JSON log format and an
application log level above `INFO` (`WARN`, `ERROR`, `FATAL`), Lambda filters
these lines out, as it does for the JS exporter's lines. `requestId` is not
included, because exporters do not receive the request id.
`max_record_size_bytes` (default 256 KB) is measured on the rendered record,
that is the `message` string. The envelope keys and the JSON escaping of
`message` add bytes on top of it, so the printed line can be up to about twice
the record size: escaping turns each `"` and `\` in the record into two bytes.
A typical record grows by about 20%. CloudWatch Logs accepts log events of up
to 1 MB (see the `PutLogEvents` API reference), so a line written for a record
at the default limit fits. A larger `max_record_size_bytes` should stay at or
below about 500 KB for this exporter to keep that margin.

### CloudWatchLogsExporter

Writes one `PutLogEvents` event per record to `log_group_name`, in a stream
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import datetime
from typing import Any

from aws_durable_execution_sdk_python_telemetry_exporter.exporters._common import (
Expand All @@ -16,11 +17,26 @@


class LambdaLogExporter:
"""Writes ``operationsByName`` records to the function's own log group via ``print``.
"""Writes ``operationsByName`` records to the function's own log group.

Port of the JS ``LambdaLogExporter``: ``console.log(JSON.stringify(
withOperationsByName(record)))``. Requires no extra IAM. Emits the name-keyed
summary map (``OPERATIONS_BY_NAME``).
Port of the JS ``LambdaLogExporter``, which calls ``console.log(JSON.stringify(
withOperationsByName(record)))``. In the Lambda JSON log format the Node.js
runtime wraps that line in a ``{timestamp, level, requestId, message}``
envelope, and the record readers for this destination query
``level = "INFO"`` and parse ``message``. The Python runtime captures
``print`` output as plain text in both log formats, so this exporter writes
the envelope itself: one stdout line of compact JSON with the keys
``timestamp`` (UTC ISO-8601, millisecond precision, ``Z``), ``level``
(``"INFO"``) and ``message`` (the compact record JSON, as a string). The
request id is not available to exporters, so ``requestId`` is omitted.
Requires no extra IAM.

``max_record_size_bytes`` limits the rendered record (the ``message``
string), as for every exporter; the envelope keys and the JSON escaping of
``message`` add bytes on top of it. The line can be up to about twice the
record (escaping doubles each ``"`` and ``\\``), about 20% more for a
typical record. CloudWatch Logs accepts events of up to 1 MB, so a record
at the default 256 KB limit fits.
"""

def __init__(self, max_record_size_bytes: int | None = None) -> None:
Expand All @@ -32,10 +48,19 @@ def render(self, record: dict[str, Any]) -> dict[str, Any]:
return with_operations_by_name(record)

def export(self, record: dict[str, Any]) -> None:
# Raw JSON line to stdout -> the function's CloudWatch log group. The
# conformance CloudWatch sink json.loads each line (and unwraps the
# Lambda structured-log envelope when present).
print(compact_dumps(self.render(record)), flush=True) # noqa: T201
timestamp = (
datetime.datetime.now(datetime.UTC)
.isoformat(timespec="milliseconds")
.replace("+00:00", "Z")
)
envelope = {
"timestamp": timestamp,
"level": "INFO",
"message": compact_dumps(self.render(record)),
}
# print (not logging): Lambda leaves stdout lines as written in both
# the Text and JSON log formats, so the envelope is never wrapped twice.
print(compact_dumps(envelope), flush=True) # noqa: T201

def flush(self) -> None:
return None
Loading
Loading