Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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
165 changes: 163 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,142 @@ 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 the SDK cannot tell which of several failures caused a wrapper, it does
not guess. It reports the wrapper's name. Two map items or parallel branches
that fail with the same message but different types are an example.

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 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,
and SDK errors such as `CallbackTimeoutError`, `SerDesError`,
`ValidationError` and `BatchCompletionError`.
- The hooks deliver checkpointed `ErrorObject`s, not live exceptions. The SDK
rebuilds a wrapper from the failed operation's `ErrorObject`, with the same
message, data and stack trace, on the first run and on replay. So one unwrap
of an operation's wrapper is a hand-off to a failed operation with the same
fields that is either a direct child, or an item or branch of a direct map or
parallel child (the map or parallel operation is a level in the tree but not
a wrapper). Other children are not searched. So a failure that user code
handled is never reported as the name. It is considered only when it is
such a direct child, item or branch with the same message and data as the
failure; the plugin then cannot tell the two apart, and the name is the
wrapper's (rule 9). A step in N nested child contexts takes N unwraps from the outermost
context: N - 1 `ChildContextError`s and the innermost context's `StepError`.
With 17 contexts the outermost one is `StepError`, and with 18 it is
`ChildContextError`; the inner contexts are `PaymentError`.
- A wrapper on the execution is the SDK re-raising the failure of the
operation it propagated from: an operation of the wrapper's kind (a step for
`StepError`, a child context for `ChildContextError`, and so on) with the
same fields. Only the outermost such operations count, and the execution
takes their name, so rule 8 also holds at the depth bound.
- When more than one distinct name is found, the plugin does not guess, and
the name is the wrapper's simple class name (for example
`ChildContextError`). This happens when two map items, or two parallel
branches, fail with the same message but different types, and
`throw_if_error()` raises for one of them. The items and branches keep their
own names.
- 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 +208,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