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.
- aws_durable_execution_sdk_python_telemetry_exporter/LICENSE +175 -0
- aws_durable_execution_sdk_python_telemetry_exporter/NOTICE +1 -0
- aws_durable_execution_sdk_python_telemetry_exporter/__about__.py +4 -0
- aws_durable_execution_sdk_python_telemetry_exporter/__init__.py +90 -0
- aws_durable_execution_sdk_python_telemetry_exporter/_export_scheduler.py +178 -0
- aws_durable_execution_sdk_python_telemetry_exporter/exporters/__init__.py +86 -0
- aws_durable_execution_sdk_python_telemetry_exporter/exporters/_common.py +109 -0
- aws_durable_execution_sdk_python_telemetry_exporter/exporters/aurora_exporter.py +152 -0
- aws_durable_execution_sdk_python_telemetry_exporter/exporters/cloudwatch_logs_exporter.py +96 -0
- aws_durable_execution_sdk_python_telemetry_exporter/exporters/dynamodb_exporter.py +75 -0
- aws_durable_execution_sdk_python_telemetry_exporter/exporters/eventbridge_exporter.py +82 -0
- aws_durable_execution_sdk_python_telemetry_exporter/exporters/file_exporter.py +88 -0
- aws_durable_execution_sdk_python_telemetry_exporter/exporters/firehose_exporter.py +63 -0
- aws_durable_execution_sdk_python_telemetry_exporter/exporters/http_exporter.py +82 -0
- aws_durable_execution_sdk_python_telemetry_exporter/exporters/lambda_log_exporter.py +41 -0
- aws_durable_execution_sdk_python_telemetry_exporter/exporters/opensearch_exporter.py +109 -0
- aws_durable_execution_sdk_python_telemetry_exporter/exporters/otel_exporter.py +151 -0
- aws_durable_execution_sdk_python_telemetry_exporter/exporters/redshift_exporter.py +155 -0
- aws_durable_execution_sdk_python_telemetry_exporter/exporters/s3_exporter.py +105 -0
- aws_durable_execution_sdk_python_telemetry_exporter/exporters/sqs_exporter.py +96 -0
- aws_durable_execution_sdk_python_telemetry_exporter/operations_index.py +132 -0
- aws_durable_execution_sdk_python_telemetry_exporter/plugin.py +452 -0
- aws_durable_execution_sdk_python_telemetry_exporter/py.typed +0 -0
- aws_durable_execution_sdk_python_telemetry_exporter/truncation.py +107 -0
- aws_durable_execution_sdk_python_telemetry_exporter/types.py +127 -0
- aws_durable_execution_sdk_python_telemetry_exporter-0.0.1.dist-info/METADATA +256 -0
- aws_durable_execution_sdk_python_telemetry_exporter-0.0.1.dist-info/RECORD +28 -0
- 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)
|
|
File without changes
|
|
@@ -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()
|