aws-durable-execution-sdk-python-telemetry-exporter 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. aws_durable_execution_sdk_python_telemetry_exporter/LICENSE +175 -0
  2. aws_durable_execution_sdk_python_telemetry_exporter/NOTICE +1 -0
  3. aws_durable_execution_sdk_python_telemetry_exporter/__about__.py +4 -0
  4. aws_durable_execution_sdk_python_telemetry_exporter/__init__.py +90 -0
  5. aws_durable_execution_sdk_python_telemetry_exporter/_export_scheduler.py +178 -0
  6. aws_durable_execution_sdk_python_telemetry_exporter/exporters/__init__.py +86 -0
  7. aws_durable_execution_sdk_python_telemetry_exporter/exporters/_common.py +109 -0
  8. aws_durable_execution_sdk_python_telemetry_exporter/exporters/aurora_exporter.py +152 -0
  9. aws_durable_execution_sdk_python_telemetry_exporter/exporters/cloudwatch_logs_exporter.py +96 -0
  10. aws_durable_execution_sdk_python_telemetry_exporter/exporters/dynamodb_exporter.py +75 -0
  11. aws_durable_execution_sdk_python_telemetry_exporter/exporters/eventbridge_exporter.py +82 -0
  12. aws_durable_execution_sdk_python_telemetry_exporter/exporters/file_exporter.py +88 -0
  13. aws_durable_execution_sdk_python_telemetry_exporter/exporters/firehose_exporter.py +63 -0
  14. aws_durable_execution_sdk_python_telemetry_exporter/exporters/http_exporter.py +82 -0
  15. aws_durable_execution_sdk_python_telemetry_exporter/exporters/lambda_log_exporter.py +41 -0
  16. aws_durable_execution_sdk_python_telemetry_exporter/exporters/opensearch_exporter.py +109 -0
  17. aws_durable_execution_sdk_python_telemetry_exporter/exporters/otel_exporter.py +151 -0
  18. aws_durable_execution_sdk_python_telemetry_exporter/exporters/redshift_exporter.py +155 -0
  19. aws_durable_execution_sdk_python_telemetry_exporter/exporters/s3_exporter.py +105 -0
  20. aws_durable_execution_sdk_python_telemetry_exporter/exporters/sqs_exporter.py +96 -0
  21. aws_durable_execution_sdk_python_telemetry_exporter/operations_index.py +132 -0
  22. aws_durable_execution_sdk_python_telemetry_exporter/plugin.py +452 -0
  23. aws_durable_execution_sdk_python_telemetry_exporter/py.typed +0 -0
  24. aws_durable_execution_sdk_python_telemetry_exporter/truncation.py +107 -0
  25. aws_durable_execution_sdk_python_telemetry_exporter/types.py +127 -0
  26. aws_durable_execution_sdk_python_telemetry_exporter-0.0.1.dist-info/METADATA +256 -0
  27. aws_durable_execution_sdk_python_telemetry_exporter-0.0.1.dist-info/RECORD +28 -0
  28. aws_durable_execution_sdk_python_telemetry_exporter-0.0.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,132 @@
1
+ # SPDX-FileCopyrightText: 2026-present Amazon.com, Inc. or its affiliates.
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Name-keyed operation summary index.
5
+
6
+ Direct port of the JS ``operations-index.ts`` (``buildOperationsByName`` /
7
+ ``withOperationsByName``). Point-access exporters (CloudWatch Logs) carry the
8
+ name-keyed ``operationsByName`` map instead of the lossless ``operations``
9
+ array. Operations without a name are skipped; a name that occurs more than once
10
+ aggregates metrics and DROPS ``result``/``error`` (no single representative
11
+ value). Scalar fields (``type``/``subType``/``status``) reflect the most-recently
12
+ seen occurrence (the runtime appends newer operations to the end of the array).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from enum import StrEnum
18
+ from typing import Any, Literal
19
+
20
+
21
+ class OperationsFormat(StrEnum):
22
+ """How an exporter renders operations in the emitted record."""
23
+
24
+ # the canonical ``operations`` array (lossless; default)
25
+ ARRAY = "array"
26
+ # replace it with the name-keyed ``operationsByName`` summary map
27
+ BY_NAME = "by-name"
28
+ # include both the array and the map
29
+ BOTH = "both"
30
+
31
+
32
+ # Accepted string inputs, kept in lockstep with the enum values above.
33
+ OperationsFormatInput = Literal["array", "by-name", "both"]
34
+
35
+
36
+ def build_operations_by_name(
37
+ operations: list[dict[str, Any]],
38
+ ) -> dict[str, dict[str, Any]]:
39
+ groups: dict[str, dict[str, Any]] = {}
40
+
41
+ for op in operations:
42
+ name = op.get("name")
43
+ if not name:
44
+ continue
45
+
46
+ duration = op.get("durationMs")
47
+ duration = duration if isinstance(duration, (int, float)) else None
48
+ attempt = op.get("attempt")
49
+ attempt = attempt if isinstance(attempt, int) else None
50
+ failed = 1 if op.get("status") == "FAILED" else 0
51
+
52
+ existing = groups.get(name)
53
+ if existing is None:
54
+ summary: dict[str, Any] = {
55
+ "type": op.get("type"),
56
+ "count": 1,
57
+ "failedCount": failed,
58
+ "status": op.get("status"),
59
+ }
60
+ if op.get("subType") is not None:
61
+ summary["subType"] = op.get("subType")
62
+ if duration is not None:
63
+ summary["minDurationMs"] = duration
64
+ summary["maxDurationMs"] = duration
65
+ summary["totalDurationMs"] = duration
66
+ if attempt is not None:
67
+ summary["maxAttempt"] = attempt
68
+ if op.get("result") is not None:
69
+ summary["result"] = op.get("result")
70
+ if op.get("error") is not None:
71
+ summary["error"] = op.get("error")
72
+ groups[name] = summary
73
+ continue
74
+
75
+ # Repeated name: aggregate and drop the per-occurrence result/error.
76
+ existing["count"] += 1
77
+ existing["failedCount"] += failed
78
+ existing["type"] = op.get("type")
79
+ existing["status"] = op.get("status")
80
+ if op.get("subType") is not None:
81
+ existing["subType"] = op.get("subType")
82
+ else:
83
+ existing.pop("subType", None)
84
+ if duration is not None:
85
+ existing["minDurationMs"] = (
86
+ duration
87
+ if existing.get("minDurationMs") is None
88
+ else min(existing["minDurationMs"], duration)
89
+ )
90
+ existing["maxDurationMs"] = (
91
+ duration
92
+ if existing.get("maxDurationMs") is None
93
+ else max(existing["maxDurationMs"], duration)
94
+ )
95
+ existing["totalDurationMs"] = (
96
+ existing.get("totalDurationMs") or 0
97
+ ) + duration
98
+ if attempt is not None:
99
+ existing["maxAttempt"] = (
100
+ attempt
101
+ if existing.get("maxAttempt") is None
102
+ else max(existing["maxAttempt"], attempt)
103
+ )
104
+ existing.pop("result", None)
105
+ existing.pop("error", None)
106
+
107
+ return groups
108
+
109
+
110
+ def with_operations_by_name(record: dict[str, Any]) -> dict[str, Any]:
111
+ """Return the record with ``operations`` replaced by ``operationsByName``."""
112
+ out = {key: value for key, value in record.items() if key != "operations"}
113
+ out["operationsByName"] = build_operations_by_name(record.get("operations", []))
114
+ return out
115
+
116
+
117
+ def apply_operations_format(
118
+ record: dict[str, Any], operations_format: OperationsFormat | OperationsFormatInput
119
+ ) -> dict[str, Any]:
120
+ """Render ``operations`` as an array, a name-keyed map, or both.
121
+
122
+ ``"array"`` returns the record unchanged. ``"by-name"`` replaces the array
123
+ with ``operationsByName``. ``"both"`` keeps the array and adds the map.
124
+ """
125
+ fmt = OperationsFormat(operations_format)
126
+ if fmt == OperationsFormat.BY_NAME:
127
+ return with_operations_by_name(record)
128
+ if fmt == OperationsFormat.BOTH:
129
+ out = dict(record)
130
+ out["operationsByName"] = build_operations_by_name(record.get("operations", []))
131
+ return out
132
+ return record
@@ -0,0 +1,452 @@
1
+ # SPDX-FileCopyrightText: 2026-present Amazon.com, Inc. or its affiliates.
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Durable Execution Telemetry plugin for the Durable Execution Python SDK.
5
+
6
+ Port of the JS ``durableExecutionTelemetry()``
7
+ (``aws-durable-execution-sdk-js-telemetry-exporter/src/index.ts``). It listens
8
+ to the SDK's instrumentation hooks and emits one curated
9
+ ``DurableExecutionTelemetry`` record per execution to the configured exporters.
10
+ The wire record keeps the JS camelCase field names so records read identically
11
+ across SDKs.
12
+
13
+ Operation-map sourcing:
14
+ The Python SDK invocation hooks now carry the full operation map directly:
15
+ ``InvocationStartInfo.operations`` (a point-in-time snapshot at invocation
16
+ start), ``InvocationEndInfo.operations`` (a fresh snapshot at invocation end),
17
+ and ``OperationChangeInfo.operations`` (the full map at the change). Alongside
18
+ them the invocation hooks carry ``execution_arn``, ``execution_start_time``,
19
+ ``execution_input`` and ``execution_result``. This plugin reads those
20
+ snapshots as the authoritative operation state -- it does NOT reconstruct the
21
+ map by accumulating per-operation ``on_operation_end`` events. Because every
22
+ invocation start re-seeds the map from the snapshot, a cold resume in a fresh
23
+ Lambda environment (a brand-new plugin instance) still reports the prior
24
+ terminal operations.
25
+
26
+ The Python SDK has no ``pluginsConfig.childOperationsDepth`` equivalent, so
27
+ ``full-tree`` records rely on the child operations being present in the
28
+ emitting invocation's snapshot (true for single-invocation and warm-resume
29
+ cases).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import datetime
35
+ import json
36
+ import math
37
+ import threading
38
+ from typing import Any, Callable
39
+
40
+ from aws_durable_execution_sdk_python.plugin import (
41
+ DurableInstrumentationPlugin,
42
+ InvocationEndInfo,
43
+ InvocationStartInfo,
44
+ InvocationStatus,
45
+ OperationChangeInfo,
46
+ OperationInfo,
47
+ OperationType,
48
+ )
49
+
50
+ from aws_durable_execution_sdk_python_telemetry_exporter._export_scheduler import (
51
+ _ExportScheduler,
52
+ )
53
+ from aws_durable_execution_sdk_python_telemetry_exporter.exporters.lambda_log_exporter import (
54
+ LambdaLogExporter,
55
+ )
56
+ from aws_durable_execution_sdk_python_telemetry_exporter.types import (
57
+ ContentConfig,
58
+ EmitMode,
59
+ TelemetryExporter,
60
+ OperationDetail,
61
+ OperationOverride,
62
+ DurableExecutionTelemetryConfig,
63
+ )
64
+
65
+
66
+ # Maps the SDK invocation status onto the record status. A durable execution
67
+ # suspends (PENDING) while waiting; from the execution's point of view it is
68
+ # still in flight, so surface it as RUNNING (mirrors the JS STATUS_MAP).
69
+ _STATUS_MAP: dict[InvocationStatus, str] = {
70
+ InvocationStatus.SUCCEEDED: "SUCCEEDED",
71
+ InvocationStatus.FAILED: "FAILED",
72
+ InvocationStatus.PENDING: "RUNNING",
73
+ InvocationStatus.RETRY: "RUNNING",
74
+ }
75
+
76
+
77
+ def _parse_execution_arn(execution_arn: str) -> dict[str, str]:
78
+ # arn:<partition>:lambda:<region>:<account>:function:<fn>:<qualifier>/durable-execution/<execName>/<invId>
79
+ parts = execution_arn.split(":")
80
+ last = parts[7] if len(parts) > 7 else ""
81
+ segments = last.split("/")
82
+ return {
83
+ "region": parts[3] if len(parts) > 3 else "",
84
+ "accountId": parts[4] if len(parts) > 4 else "",
85
+ "functionName": parts[6] if len(parts) > 6 else "",
86
+ "qualifier": segments[0] if len(segments) > 0 else "",
87
+ "executionName": segments[2] if len(segments) > 2 else "",
88
+ "invocationId": segments[3] if len(segments) > 3 else "",
89
+ }
90
+
91
+
92
+ def _fnv1a32(value: str) -> int:
93
+ h = 0x811C9DC5
94
+ for ch in value:
95
+ h ^= ord(ch) & 0xFF
96
+ h = (h * 0x01000193) & 0xFFFFFFFF
97
+ return h
98
+
99
+
100
+ def _should_sample(execution_arn: str, rate: float) -> bool:
101
+ if rate >= 1:
102
+ return True
103
+ if rate <= 0:
104
+ return False
105
+ return _fnv1a32(execution_arn) / 0xFFFFFFFF < rate
106
+
107
+
108
+ def _resolve_sampling_rate(rate: float | None) -> float:
109
+ if rate is None:
110
+ return 1.0
111
+ if not isinstance(rate, (int, float)):
112
+ return 1.0
113
+ if isinstance(rate, float) and math.isnan(rate):
114
+ # Fail open. NaN compares False to everything, so without this guard it
115
+ # would flow through _should_sample (rate >= 1 -> False, rate <= 0 ->
116
+ # False, x < NaN -> False) and silently sample OUT every execution,
117
+ # disabling all instrumentation. Coerce to full sampling instead, which
118
+ # matches the JS plugin's treatment of non-finite/invalid rates.
119
+ return 1.0
120
+ if rate < 0 or rate > 1:
121
+ return max(0.0, min(1.0, float(rate)))
122
+ return float(rate)
123
+
124
+
125
+ def _iso(ts: Any) -> str | None:
126
+ if isinstance(ts, datetime.datetime):
127
+ return ts.astimezone(datetime.UTC).isoformat().replace("+00:00", "Z")
128
+ return None
129
+
130
+
131
+ def _duration_ms(start: Any, end: Any) -> int | None:
132
+ if isinstance(start, datetime.datetime) and isinstance(end, datetime.datetime):
133
+ return int((end - start).total_seconds() * 1000)
134
+ return None
135
+
136
+
137
+ def _apply_data_content(value: Any, setting: Any) -> Any:
138
+ if setting is False:
139
+ return None
140
+ if value is None:
141
+ return None
142
+ if callable(setting):
143
+ try:
144
+ return setting(value)
145
+ except Exception: # noqa: BLE001 - a failing redactor must never leak the raw value
146
+ return None
147
+ return value
148
+
149
+
150
+ def _apply_result_override(
151
+ transform: Callable[[Any], Any], raw_result: str | None
152
+ ) -> Any:
153
+ if raw_result is None:
154
+ return None
155
+ try:
156
+ parsed = json.loads(raw_result)
157
+ except (json.JSONDecodeError, TypeError):
158
+ parsed = raw_result
159
+ try:
160
+ return transform(parsed)
161
+ except Exception: # noqa: BLE001 - untrusted transform must never break emission
162
+ return None
163
+
164
+
165
+ class _ExecutionState:
166
+ __slots__ = ("start_time", "parsed_arn", "cached_input", "operations")
167
+
168
+ def __init__(self, start_time: Any, parsed_arn: dict[str, str]) -> None:
169
+ self.start_time = start_time
170
+ self.parsed_arn = parsed_arn
171
+ self.cached_input: Any = None
172
+ # operation_id -> OperationInfo, adopted verbatim from the SDK's
173
+ # authoritative snapshot (invocation start/end and operation-change).
174
+ self.operations: dict[str, OperationInfo] = {}
175
+
176
+
177
+ class DurableExecutionTelemetryPlugin(DurableInstrumentationPlugin):
178
+ def __init__(self, config: DurableExecutionTelemetryConfig) -> None:
179
+ self._sampling_rate = _resolve_sampling_rate(config.sampling_rate)
180
+ # config.emit_mode / operation_detail are already normalized to enum
181
+ # members (or None) by DurableExecutionTelemetryConfig.__post_init__;
182
+ # re-wrap to satisfy the static type of the union-typed config fields.
183
+ self._emit_mode: EmitMode = (
184
+ EmitMode(config.emit_mode)
185
+ if config.emit_mode is not None
186
+ else EmitMode.ON_COMPLETE
187
+ )
188
+ detail = (
189
+ OperationDetail(config.operation_detail)
190
+ if config.operation_detail is not None
191
+ else OperationDetail.TOP_LEVEL
192
+ )
193
+ self._top_level_only = detail != OperationDetail.FULL_TREE
194
+ content: ContentConfig | None = config.content
195
+ self._content = content
196
+ ops = content.operations if content and content.operations else None
197
+ self._include_errors = (
198
+ True if ops is None or ops.include_errors is None else ops.include_errors
199
+ )
200
+ self._overrides_by_name: dict[str, OperationOverride] = {}
201
+ if ops is not None:
202
+ for override in ops.overrides:
203
+ self._overrides_by_name[override.operation_name] = override
204
+ # Default-exporter parity with the JS plugin: an omitted OR an explicitly
205
+ # empty exporter list falls back to the Lambda log exporter, so the
206
+ # plugin is never a silent no-op. A non-empty list is used verbatim.
207
+ self._exporters: list[TelemetryExporter] = (
208
+ list(config.exporters) if config.exporters else [LambdaLogExporter()]
209
+ )
210
+ self._scheduler = _ExportScheduler(self._exporters)
211
+ self._state: dict[str, _ExecutionState] = {}
212
+ self._lock = threading.Lock()
213
+
214
+ # -- sampling / state -----------------------------------------------------
215
+
216
+ def _sampled_in(self, execution_arn: str) -> bool:
217
+ # Deterministic per-ARN, so every hook for one execution agrees without
218
+ # needing to persist the decision in state.
219
+ return _should_sample(execution_arn, self._sampling_rate)
220
+
221
+ def _ensure_state(self, execution_arn: str) -> _ExecutionState:
222
+ with self._lock:
223
+ state = self._state.get(execution_arn)
224
+ if state is None:
225
+ state = _ExecutionState(
226
+ start_time=datetime.datetime.now(datetime.UTC),
227
+ parsed_arn=_parse_execution_arn(execution_arn),
228
+ )
229
+ self._state[execution_arn] = state
230
+ return state
231
+
232
+ def _discard_state(self, execution_arn: str) -> None:
233
+ with self._lock:
234
+ self._state.pop(execution_arn, None)
235
+
236
+ def _adopt_operations(
237
+ self, state: _ExecutionState, operations: dict[str, OperationInfo]
238
+ ) -> None:
239
+ # Adopt the authoritative point-in-time snapshot. Copy so plugin state
240
+ # never aliases the SDK-owned map, and rebind the attribute so a
241
+ # concurrent reader holding the prior reference iterates a stable dict.
242
+ with self._lock:
243
+ state.operations = dict(operations)
244
+
245
+ # -- hooks ----------------------------------------------------------------
246
+
247
+ def on_invocation_start(self, info: InvocationStartInfo) -> None:
248
+ arn = info.execution_arn
249
+ if not arn or not self._sampled_in(arn):
250
+ return
251
+ state = self._ensure_state(arn)
252
+ # Always adopt the service-provided execution start time when present,
253
+ # including a cold resume in a fresh environment (never the resume time,
254
+ # which would corrupt duration and the date partition).
255
+ if info.execution_start_time is not None:
256
+ state.start_time = info.execution_start_time
257
+ state.cached_input = info.execution_input
258
+ # Seed the operation map from the full snapshot on every invocation. On a
259
+ # cold resume this rebuilds prior (terminal) operations that a fresh
260
+ # plugin instance never saw via per-operation hooks.
261
+ self._adopt_operations(state, info.operations)
262
+ if self._emit_mode == EmitMode.ON_CHANGE:
263
+ self._emit(
264
+ arn,
265
+ state,
266
+ status="RUNNING",
267
+ end_time=None,
268
+ output_raw=None,
269
+ error=None,
270
+ )
271
+
272
+ def on_operation_change(self, info: OperationChangeInfo) -> None:
273
+ arn = info.execution_arn
274
+ if not arn or not self._sampled_in(arn):
275
+ return
276
+ state = self._ensure_state(arn)
277
+ # Replace state with the full operations snapshot carried by the hook.
278
+ self._adopt_operations(state, info.operations)
279
+ # on-change mode exports an updated RUNNING record on each change so
280
+ # mid-invocation progress is observable, not only at start/end.
281
+ if self._emit_mode == EmitMode.ON_CHANGE:
282
+ self._emit(
283
+ arn,
284
+ state,
285
+ status="RUNNING",
286
+ end_time=None,
287
+ output_raw=None,
288
+ error=None,
289
+ )
290
+
291
+ def on_invocation_end(self, info: InvocationEndInfo) -> None:
292
+ arn = info.execution_arn
293
+ if not arn:
294
+ return
295
+ if not self._sampled_in(arn):
296
+ # Sampled-out executions process no operations and retain no state.
297
+ self._discard_state(arn)
298
+ return
299
+ state = self._ensure_state(arn)
300
+ # Refresh from the fresh end-of-invocation snapshot before emitting so
301
+ # the terminal record reflects the final operation map.
302
+ self._adopt_operations(state, info.operations)
303
+ status = _STATUS_MAP.get(info.status, "RUNNING")
304
+ is_terminal = status in ("SUCCEEDED", "FAILED")
305
+ is_failure = status == "FAILED"
306
+
307
+ if self._emit_mode == EmitMode.ON_CHANGE:
308
+ should_emit = True
309
+ elif self._emit_mode == EmitMode.ON_FAILURE:
310
+ should_emit = is_failure
311
+ else: # on-complete
312
+ should_emit = is_terminal
313
+
314
+ if should_emit:
315
+ # Only terminal (SUCCEEDED/FAILED) records carry an end time; a
316
+ # PENDING/RETRY invocation end maps to RUNNING (still in flight) and
317
+ # must omit endTime/durationMs. Passing end_time=None makes _emit
318
+ # drop both fields. Output and error likewise belong only to a
319
+ # terminal record.
320
+ self._emit(
321
+ arn,
322
+ state,
323
+ status=status,
324
+ end_time=datetime.datetime.now(datetime.UTC) if is_terminal else None,
325
+ output_raw=info.execution_result if is_terminal else None,
326
+ error=info.error if is_terminal else None,
327
+ )
328
+ self._scheduler.drain()
329
+
330
+ # Clear state after EVERY invocation end, including PENDING/RETRY. The
331
+ # next invocation rebuilds it from InvocationStartInfo.operations, so a
332
+ # suspended execution that never resumes in this environment (or that was
333
+ # sampled out) leaks nothing and state stays bounded.
334
+ self._discard_state(arn)
335
+
336
+ # -- emission -------------------------------------------------------------
337
+
338
+ def _build_operations(
339
+ self, operations: dict[str, OperationInfo]
340
+ ) -> list[dict[str, Any]]:
341
+ records: list[dict[str, Any]] = []
342
+ for op in operations.values():
343
+ if op.operation_type == OperationType.EXECUTION:
344
+ continue
345
+ if not op.name:
346
+ continue
347
+ if self._top_level_only and op.parent_id:
348
+ continue
349
+ override = self._overrides_by_name.get(op.name)
350
+ if override is not None and override.exclude:
351
+ continue
352
+
353
+ entry: dict[str, Any] = {"id": op.operation_id, "name": op.name}
354
+ entry["type"] = op.operation_type.value
355
+ if op.sub_type is not None:
356
+ entry["subType"] = op.sub_type.value
357
+ if op.parent_id is not None:
358
+ entry["parentId"] = op.parent_id
359
+ entry["status"] = op.status.value if op.status is not None else "UNKNOWN"
360
+ start_iso = _iso(op.start_time)
361
+ if start_iso is not None:
362
+ entry["startTime"] = start_iso
363
+ end_iso = _iso(op.end_time)
364
+ if end_iso is not None:
365
+ entry["endTime"] = end_iso
366
+ dur = _duration_ms(op.start_time, op.end_time)
367
+ if dur is not None:
368
+ entry["durationMs"] = dur
369
+ if op.attempt is not None:
370
+ entry["attempt"] = op.attempt
371
+ if self._include_errors and op.error is not None:
372
+ entry["error"] = {"name": op.error.type, "message": op.error.message}
373
+ if override is not None and override.result is not None:
374
+ value = _apply_result_override(override.result, op.result)
375
+ if value is not None:
376
+ entry["result"] = value
377
+ records.append(entry)
378
+ return records
379
+
380
+ def _emit(
381
+ self,
382
+ execution_arn: str,
383
+ state: _ExecutionState,
384
+ *,
385
+ status: str,
386
+ end_time: Any,
387
+ output_raw: str | None,
388
+ error: Any,
389
+ ) -> None:
390
+ arn = state.parsed_arn
391
+ start_time = state.start_time
392
+ duration = _duration_ms(start_time, end_time)
393
+ # Snapshot the operations reference once so a concurrent adopt() rebind
394
+ # cannot change the map mid-build.
395
+ operations = state.operations
396
+
397
+ content = self._content
398
+ record: dict[str, Any] = {
399
+ "recordType": "DurableExecutionTelemetry",
400
+ "schemaVersion": "1.0",
401
+ "emittedAt": datetime.datetime.now(datetime.UTC)
402
+ .isoformat()
403
+ .replace("+00:00", "Z"),
404
+ "executionArn": execution_arn,
405
+ }
406
+ if arn.get("executionName"):
407
+ record["executionName"] = arn["executionName"]
408
+ record["functionName"] = arn.get("functionName", "")
409
+ record["functionQualifier"] = arn.get("qualifier", "")
410
+ record["region"] = arn.get("region", "")
411
+ record["accountId"] = arn.get("accountId", "")
412
+ record["status"] = status
413
+ start_iso = _iso(start_time)
414
+ if start_iso is not None:
415
+ record["startTime"] = start_iso
416
+ end_iso = _iso(end_time)
417
+ if end_iso is not None:
418
+ record["endTime"] = end_iso
419
+ if duration is not None:
420
+ record["durationMs"] = duration
421
+
422
+ parsed_output: Any = None
423
+ if output_raw is not None and output_raw != "":
424
+ try:
425
+ parsed_output = json.loads(output_raw)
426
+ except (json.JSONDecodeError, TypeError):
427
+ parsed_output = output_raw
428
+ input_value = _apply_data_content(
429
+ state.cached_input, content.input if content else None
430
+ )
431
+ output_value = _apply_data_content(
432
+ parsed_output, content.output if content else None
433
+ )
434
+ if input_value is not None:
435
+ record["input"] = input_value
436
+ if output_value is not None:
437
+ record["output"] = output_value
438
+ if error is not None:
439
+ record["error"] = {"name": error.type, "message": error.message}
440
+ record["operations"] = self._build_operations(operations)
441
+
442
+ self._scheduler.schedule(record)
443
+
444
+
445
+ def durable_execution_telemetry(
446
+ config: DurableExecutionTelemetryConfig,
447
+ ) -> DurableExecutionTelemetryPlugin:
448
+ """Create a Durable Execution Telemetry plugin.
449
+
450
+ Mirrors the JS ``durableExecutionTelemetry()`` factory.
451
+ """
452
+ return DurableExecutionTelemetryPlugin(config)
@@ -0,0 +1,107 @@
1
+ # SPDX-FileCopyrightText: 2026-present Amazon.com, Inc. or its affiliates.
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Best-effort record size limiter.
5
+
6
+ Direct port of the JS ``truncation.ts``. Drop order:
7
+ 1. operation ``result`` fields, oldest operation first (each dropped op marked
8
+ ``truncated: true``);
9
+ 2. whole operations, oldest first (``droppedOperations`` count);
10
+ 3. last resort — execution ``input`` then ``output`` (``droppedInput`` /
11
+ ``droppedOutput``).
12
+
13
+ Identity/timeline fields are never dropped. The input record is never mutated.
14
+ ``render`` maps the record to the exact value the exporter serializes, so the
15
+ size check measures what is actually emitted. Byte size is measured with
16
+ JS-compatible compact JSON (no whitespace, non-ASCII preserved) to match
17
+ ``JSON.stringify`` byte counts.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from typing import Any, Callable
24
+
25
+
26
+ def json_byte_size(value: Any) -> int | None:
27
+ try:
28
+ return len(
29
+ json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
30
+ )
31
+ except (TypeError, ValueError):
32
+ return None
33
+
34
+
35
+ def truncate_record(
36
+ record: dict[str, Any],
37
+ max_bytes: int | None,
38
+ render: Callable[[dict[str, Any]], Any] | None = None,
39
+ ) -> dict[str, Any]:
40
+ render = render or (lambda r: r)
41
+ if max_bytes is None or max_bytes <= 0:
42
+ return record
43
+
44
+ initial = json_byte_size(render(record))
45
+ if initial is None or initial <= max_bytes:
46
+ return record
47
+
48
+ ops: list[dict[str, Any]] = [dict(op) for op in record.get("operations", [])]
49
+ kept = [True] * len(ops)
50
+ # Oldest-first by ISO startTime string (UTC 'Z' ISO timestamps sort
51
+ # chronologically as strings); operations without a startTime sort last.
52
+ order = sorted(
53
+ range(len(ops)), key=lambda i: (ops[i].get("startTime") or "\uffff", i)
54
+ )
55
+
56
+ any_result = False
57
+ dropped_ops = 0
58
+ dropped_input = False
59
+ dropped_output = False
60
+
61
+ def candidate() -> dict[str, Any]:
62
+ out = dict(record)
63
+ out["operations"] = [op for i, op in enumerate(ops) if kept[i]]
64
+ out["truncated"] = True
65
+ if dropped_ops > 0:
66
+ out["droppedOperations"] = dropped_ops
67
+ if dropped_input:
68
+ out.pop("input", None)
69
+ out["droppedInput"] = True
70
+ if dropped_output:
71
+ out.pop("output", None)
72
+ out["droppedOutput"] = True
73
+ return out
74
+
75
+ def fits() -> bool:
76
+ size = json_byte_size(render(candidate()))
77
+ return size is not None and size <= max_bytes
78
+
79
+ # Phase 1: drop operation results oldest-first.
80
+ for idx in order:
81
+ if fits():
82
+ break
83
+ if kept[idx] and ops[idx].get("result") is not None:
84
+ trimmed = dict(ops[idx])
85
+ trimmed.pop("result", None)
86
+ trimmed["truncated"] = True
87
+ ops[idx] = trimmed
88
+ any_result = True
89
+
90
+ # Phase 2: drop whole operations oldest-first.
91
+ for idx in order:
92
+ if fits():
93
+ break
94
+ if kept[idx]:
95
+ kept[idx] = False
96
+ dropped_ops += 1
97
+
98
+ # Phase 3 (last resort): drop input then output.
99
+ if not fits() and record.get("input") is not None:
100
+ dropped_input = True
101
+ if not fits() and record.get("output") is not None:
102
+ dropped_output = True
103
+
104
+ if not any_result and dropped_ops == 0 and not dropped_input and not dropped_output:
105
+ return record
106
+
107
+ return candidate()