Skip to content

fix(telemetry): write error shape, plain results and log envelope - #808

Merged
ParidelPooya merged 7 commits into
mainfrom
fix/telemetry-error-cause-format
Oct 22, 2026
Merged

ParidelPooya merged 7 commits into
mainfrom
fix/telemetry-error-cause-format

Conversation

@ParidelPooya

@ParidelPooya ParidelPooya commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Changes only packages/aws-durable-execution-sdk-python-telemetry-exporter. No package version changes.

  • Every error in a record is written with one shape and key order. Every error name and message is capped at 8,192 code points.
  • An opted-in operation result written by the default serializer (ExtendedTypeSerDes) is decoded from its {"t","v"} envelope to plain JSON. Numbers stay exact for JavaScript readers and storable in DynamoDB.
  • LambdaLogExporter prints the Lambda JSON log envelope, so readers that query level and parse message find the records. max_record_size_bytes caps the printed line.
  • README: sections "LambdaLogExporter", "DynamoDBExporter", "Error names and causes" and "Operation results".

The shape

The execution error, every operations[].error and every operationsByName.<name>.error:

"error": { "name": "...", "message": "...", "cause": { "name": "...", "message": "...", "cause": { } } }

Keys are in the order name, message, cause. name is the SDK's error type and this PR does not change it. cause is present only when the SDK provides one.

Python records never include cause. The plugin hooks carry only ErrorObject (type, message, data, stack_trace). ErrorObject has no cause field, and the hooks do not pass an exception, so there is no __cause__ to read. The exporter does not build a cause from other operations or from ErrorData. Because no cause is ever written, the 16-level depth limit and the cycle stop have no Python code path.

  • Name and message cap: a name or message longer than 8,192 code points keeps its first 8,192 code points followed by …. A Python str is a sequence of code points, so a cut never splits a character. It is applied once, when the error is written.
  • Truncation: when a record is over the limit, the first step removes every cause and sets droppedCauses: true. The existing steps follow. Python records have no cause, so this step never changes them. It is there so the field means the same in every SDK.
  • include_errors=False still removes operation errors.

Error names differ by language

Each SDK uses its own names, and they are not aligned. A step that throws PaymentError can be reported as StepError in JS, aws_durable_execution_sdk_python.exceptions.StepError or PaymentError in Python, and StepFailedException or PaymentError in Java. Query names per SDK, or query on message and the cause chain.

Operation results

When the checkpointed string is the default serializer's envelope (its root has exactly the keys t and v, and t is a string), it is decoded with that serializer and converted to plain JSON:

  • int: unchanged when abs(n) <= 2**53 - 1, otherwise str(n), for example "12345678901234567".
  • Decimal:
    • an int when it is integral and abs(n) <= 2**53 - 1
    • otherwise a float, when the float round-trips exactly and is within DynamoDB's number range (magnitude 1E-130 to 9.99...E+125; a float written exactly never has more than 17 significant digits)
    • otherwise str(value), for example "9007199254740993", "1E+299" or a 39-digit integer
    • NaN and Infinity become null
    • negative zero becomes 0 (documented)
  • datetime/date → isoformat(), UUID → string, bytes → base64, tuple → list, BatchResult → to_dict().
  • Any other payload is parsed JSON, or the raw string when it is not JSON. NaN and infinite numbers become null, and an integer with abs(n) > 2**53 - 1 becomes its decimal string. Parsed JSON is walked only when json.loads saw NaN or an infinite number, or the raw text has a run of 16 or more digits.
  • Conversion is iterative, with no recursion, and decoding never raises. A result that fails to decode keeps its parsed or raw value, and the rest of the record is still emitted.
  • Envelope collisions: the envelope is recognized by shape alone, so a JsonSerDes or custom result with that shape is decoded too. Only the core's known tags are decoded, and the core may coerce v ({"t":"b","v":"yes"} becomes true). Unknown tags pass through unchanged. The README documents this.

DynamoDBExporter also writes any number it cannot store as a string attribute holding its JSON text. This covers numbers outside the 38-digit and magnitude range, NaN and Infinity, so a plain JSON result cannot make the item fail either. An integer with more than 38 digits but at most 38 significant digits, such as 10**38, is stored as a number. The README notes that a field can be N in one item and S in another.

LambdaLogExporter

Each line is {"timestamp":"<UTC ms Z>","level":"INFO","message":"<compact record JSON>"}, with keys in that order.

  • render returns this envelope, so the existing single truncation pass measures the printed line, including the second JSON escaping of message. There is no second pass. The limit is in UTF-8 bytes and excludes the trailing newline.
  • The default limit stays at 256,000 bytes.
  • With the name and message caps, a record whose operations, input and output are dropped fits any limit of 64 KB or more, assuming ARN fields and an error name of normal length. Tests cover an 8,192-character message of control characters and a 70,000-character name, each at 64 KB. Without the name assumption, the worst case (name and message both 8,192 control characters) fits 128 KB (131,072 bytes), and a test covers it. The README states these assumptions.
  • U+2028 and U+2029 are written as \u2028 and \u2029. A lone surrogate is written as its \uXXXX escape, so printing it never raises.
  • LambdaLogExporter.measure returns the exact size of the printed line, escapes included, and truncate_record takes it as an optional measure function. Every other exporter is measured as the compact JSON it writes, so U+2028 and U+2029 count as 3 bytes there. A value that cannot be measured (a lone surrogate under plain UTF-8) is kept unchanged rather than raising.
  • README: INFO lines are filtered only with the JSON log format and an application log level of WARN or above.

Behaviour changes

The telemetry packages are not published yet. This PR does not change any package version.

  • An opted-in operation result written by the default serializer is passed to the transform as plain JSON, not as the {"t","v"} envelope.
  • LambdaLogExporter lines are the Lambda JSON log envelope, and max_record_size_bytes caps the line, not the record.
  • Error names and messages longer than 8,192 code points are cut and end with ….
  • Integer results beyond 2**53 - 1 are written as strings.
  • DynamoDBExporter writes numbers it cannot store as strings instead of failing the item.

Tests

tests/test_error_and_result_format.py:

  • error shape and key order for the execution error, operations[].error and operationsByName
  • a missing type or message
  • include_errors=False
  • a test that ErrorObject has no cause field
  • envelope decoding for every listed type
  • Decimal cases:
    • the 2**53 - 1 boundary, 9007199254740993
    • 1E+125/1E+126, 1E-130/1E-131, 1E+299, 1E+300, a 39-digit integer
    • 1E+5000, 1E±1000000
    • NaN, sNaN, ±Infinity, -0, -0.0
  • envelope collisions (s, i, m, b coercion, unknown tag)
  • deep nesting, and one result failing while the rest of the record is still emitted
  • The wall-clock < 0.5 s assertion is replaced by an operation count: a Decimal past the bound must convert with zero int() calls.
  • A spy checks that plain JSON skips the conversion walk unless it holds NaN or Infinity.

New tests/test_public_path_limits.py, which goes through the public plugin path:

  • DynamoDB: plugin → DynamoDBExporter with a stub client and boto3's real TypeSerializer, for Decimal("1E+299"), a 39-digit integer and 9007199254740993. These are written as strings and the item is kept. Also covered: plain JSON 1e300, a 39-digit int and 1e-200, the DynamoDB magnitude bounds, and NaN/Infinity.
  • LambdaLogExporter:
    • a quote-heavy record at a 262,144 limit: the printed line is at most the limit, and results that fit are kept
    • a capped execution error at a 64 KB limit
    • U+2028/U+2029 escaping, control characters, non-ASCII and emoji, round-tripped
    • the escaping counted against the limit
  • Name and message cap: the 8,192 boundary on the execution error and on operation errors, a 70,000-character name, and 8,193 emoji (8,192 kept, counted as code points).
  • Lone surrogate: an execution error message "a\ud800b" through the plugin into DynamoDBExporter with a stub client writes one item. Through LambdaLogExporter with a strict UTF-8 stdout, the line is printed with \ud800 escaped and round-trips. Lone surrogates are counted in the line limit.
  • Exact sizes: 60,000 U+2028 (180,000 bytes) under a 200,000-byte DynamoDBExporter limit is not truncated. LambdaLogExporter.measure equals the printed line's size.
  • Integers: envelope and plain JSON ints at and beyond 2**53 - 1, negative values, nested values, and long digit runs in strings and floats left alone. DynamoDBExporter stores 10**38 and 10**125 as numbers.

tests/test_shaping.py: the cause step runs first and does not mutate the input, droppedCauses is absent when there were no causes, json_byte_size is exact for U+2028 and returns None for a lone surrogate, and truncate_record uses a given measure.

Run with the existing hatch envs:

Command Result
hatch fmt --check passed
hatch run types:check passed (no issues in any package)
hatch run test:cov 5416 passed, 2 skipped, 2 failed
hatch build (exporter package) sdist and wheel built, version 1.0.0
.github/scripts/lintcommit.py --range origin/main..HEAD PASS (6 commits)

The two test:cov failures are test_package_metadata.py::test_public_version_matches_installed_stable_release in the telemetry-exporter and otel packages ('1.0.0' == '0.0.1'). The cause is stale installed metadata in the local test env, which was not reinstalled. They are not related to this change.

Not verified

  • Not run in a deployed Lambda function. The envelope behaviour under the Text and JSON log formats and under log-level filtering comes from the Lambda documentation, not from a test in this PR.
  • Not tested against the real DynamoDB service. The range check follows boto3's DYNAMODB_CONTEXT and the documented 1E-130 to 9.99...E+125 range.
  • Not checked end to end against the JS and Java readers.

The JS and Java exporter PRs write the same error shape.

Write every record error as {name, message} in that key order. The
plugin hooks carry only ErrorObject, which has no cause, so Python
records never have a cause. Names are unchanged.

Decode an opted-in operation result that is the default
ExtendedTypeSerDes envelope into plain JSON values. A result that
fails to decode keeps its parsed or raw value.

LambdaLogExporter now prints the Lambda JSON log envelope
{"timestamp","level":"INFO","message":"<record JSON>"}.
@ParidelPooya
ParidelPooya deployed to ai-pr-review-runtime October 22, 2026 17:24 — with GitHub Actions Active
@ParidelPooya
ParidelPooya deployed to ai-pr-review-runtime October 22, 2026 17:25 — with GitHub Actions Active
@github-actions

This comment has been minimized.

Decode an integral Decimal to an int only when abs(n) <= 2**53 - 1.
Otherwise write a float only when it round-trips exactly and is within
DynamoDB's number range (38 significant digits, magnitude 1E-130 to
9.99...E+125), else str(value). DynamoDBExporter also writes any number
it cannot store, including NaN and Infinity, as a string attribute, so
a decoded or plain JSON result no longer makes the item fail.

LambdaLogExporter.render returns the printed envelope, so the existing
single truncation pass measures the line. U+2028 and U+2029 are
escaped in the line, and the size check counts them escaped.

Cap every error message at 8,192 characters plus an ellipsis.
Truncation first drops every cause and sets droppedCauses; Python
records have no cause, so this step does not change them.

Plain JSON results skip the conversion walk unless they contain NaN or
an infinite number. Replace the wall-clock test assertion with a count
of int conversions.
Document the Decimal rules, including negative zero, the DynamoDB
string fallback, the line limit of LambdaLogExporter, the message cap,
the cause truncation step, and that Python records have no cause.
State that WARN filtering applies only with the JSON log format, and
which envelope-shaped results are decoded or passed through.
@ParidelPooya
ParidelPooya deployed to ai-pr-review-runtime October 22, 2026 18:43 — with GitHub Actions Active
@github-actions

This comment has been minimized.

json_byte_size encodes inside its try again, so a lone surrogate makes
the size unknown and the record is kept instead of raising and being
dropped by every size-limited exporter. json_byte_size is exact again:
it no longer counts U+2028 and U+2029 as escapes.

truncate_record takes an optional measure function, and the scheduler
passes an exporter's measure when it has one. LambdaLogExporter
measures the exact printed line, which escapes U+2028, U+2029 and lone
surrogates as \uXXXX, so printing a lone surrogate never raises and
other exporters no longer over-count.
Cap every error name, like the message, at 8,192 code points followed
by an ellipsis.

An int result, from an envelope or from plain JSON, whose absolute
value is greater than 2**53 - 1 is written as its decimal string, like
a Decimal. Plain JSON is only walked when it has a run of 16 or more
digits, NaN or an infinite number. Remove the 38-digit check on the
float path, which could never fail.

DynamoDBExporter stores an integer with more than 38 digits but at most
38 significant digits, such as 10**38, as a number.
State that names and messages are capped at 8,192 code points, the
ARN and name length assumptions behind the 64 KB statement, the 128 KB
worst case, the 2**53 - 1 bound for int results, lone surrogate
escaping in LambdaLogExporter, and that a DynamoDB attribute can be a
number in one item and a string in another.
@ParidelPooya
ParidelPooya force-pushed the fix/telemetry-error-cause-format branch from b5233d2 to 060c382 Compare October 22, 2026 19:35
@ParidelPooya
ParidelPooya deployed to ai-pr-review-runtime October 22, 2026 19:36 — with GitHub Actions Active
@github-actions

This comment has been minimized.

Resolves conflicts with the per-invocation plugin factory (#737). The error shape, result decoding and log envelope are reapplied on top of main's plugin, and the new test helpers now create the plugin through the factory.
@ParidelPooya
ParidelPooya deployed to ai-pr-review-runtime October 22, 2026 20:52 — with GitHub Actions Active
@github-actions

Copy link
Copy Markdown
Contributor

Codex AI review

No actionable findings. The three prior findings are resolved. Review was static; tests were not run, and live Lambda/DynamoDB behavior was not exercised.

Reviewed commit 4754afdabe0d4c500f9b4e1c7d9c0349403df4b4. Workflow run

# adjusted() is checked first so Decimal("1E+1000000") never builds a
# million-digit int.
if (
value.adjusted() <= _MAX_SAFE_INTEGER_ADJUSTED

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A Decimal zero can carry a positive exponent. Decimal("-0E+20").adjusted() is 20, so the current integer check skips it. The range check also skips it because zero is false. The float path then emits -0.0, while the README says negative zero becomes integer 0. Can this return 0 for every zero Decimal before checking adjusted()?

The existing tests cover -0 and -0.0, but neither has a large positive exponent. Adding ("-0E+20", 0) and ("0E+16", 0) would cover both signs. This one is optional.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed on main: Decimal("-0E+20") gave -0.0 and Decimal("0E+16") gave 0.0. Fixed in #813. Every zero Decimal now returns 0 before the exponent checks. Your two cases and 0E-200 are added to the test, which also checks the type.

@ParidelPooya
ParidelPooya merged commit f870be5 into main Oct 22, 2026
25 of 26 checks passed
@ParidelPooya
ParidelPooya deleted the fix/telemetry-error-cause-format branch October 22, 2026 22:40
zhongkechen pushed a commit that referenced this pull request Oct 22, 2026
* fix(telemetry): write error shape, plain results and log envelope

Write every record error as {name, message} in that key order. The
plugin hooks carry only ErrorObject, which has no cause, so Python
records never have a cause. Names are unchanged.

Decode an opted-in operation result that is the default
ExtendedTypeSerDes envelope into plain JSON values. A result that
fails to decode keeps its parsed or raw value.

LambdaLogExporter now prints the Lambda JSON log envelope
{"timestamp","level":"INFO","message":"<record JSON>"}.

* fix(telemetry): bound numbers and the log line

Decode an integral Decimal to an int only when abs(n) <= 2**53 - 1.
Otherwise write a float only when it round-trips exactly and is within
DynamoDB's number range (38 significant digits, magnitude 1E-130 to
9.99...E+125), else str(value). DynamoDBExporter also writes any number
it cannot store, including NaN and Infinity, as a string attribute, so
a decoded or plain JSON result no longer makes the item fail.

LambdaLogExporter.render returns the printed envelope, so the existing
single truncation pass measures the line. U+2028 and U+2029 are
escaped in the line, and the size check counts them escaped.

Cap every error message at 8,192 characters plus an ellipsis.
Truncation first drops every cause and sets droppedCauses; Python
records have no cause, so this step does not change them.

Plain JSON results skip the conversion walk unless they contain NaN or
an infinite number. Replace the wall-clock test assertion with a count
of int conversions.

* docs(telemetry): describe number, line size and message limits

Document the Decimal rules, including negative zero, the DynamoDB
string fallback, the line limit of LambdaLogExporter, the message cap,
the cause truncation step, and that Python records have no cause.
State that WARN filtering applies only with the JSON log format, and
which envelope-shaped results are decoded or passed through.

* fix(telemetry): keep lone surrogates and measure sizes exactly

json_byte_size encodes inside its try again, so a lone surrogate makes
the size unknown and the record is kept instead of raising and being
dropped by every size-limited exporter. json_byte_size is exact again:
it no longer counts U+2028 and U+2029 as escapes.

truncate_record takes an optional measure function, and the scheduler
passes an exporter's measure when it has one. LambdaLogExporter
measures the exact printed line, which escapes U+2028, U+2029 and lone
surrogates as \uXXXX, so printing a lone surrogate never raises and
other exporters no longer over-count.

* fix(telemetry): cap error names and bound large integers

Cap every error name, like the message, at 8,192 code points followed
by an ellipsis.

An int result, from an envelope or from plain JSON, whose absolute
value is greater than 2**53 - 1 is written as its decimal string, like
a Decimal. Plain JSON is only walked when it has a run of 16 or more
digits, NaN or an infinite number. Remove the 38-digit check on the
float path, which could never fail.

DynamoDBExporter stores an integer with more than 38 digits but at most
38 significant digits, such as 10**38, as a number.

* docs(telemetry): describe name cap, int bound and size assumptions

State that names and messages are capped at 8,192 code points, the
ARN and name length assumptions behind the 64 KB statement, the 128 KB
worst case, the 2**53 - 1 bound for int results, lone surrogate
escaping in LambdaLogExporter, and that a DynamoDB attribute can be a
number in one item and a string in another.

This branch was successfully deployed

1 active deployment
ai-pr-review-runtime — 4754afda Deployed Oct 22, 2026 by ParidelPooya via ai-pr-review / Codex review / Generate Codex review #2108
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants