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,109 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present Amazon.com, Inc. or its affiliates.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""OpenSearch (Index API) Durable Execution Telemetry exporter."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
from aws_durable_execution_sdk_python_telemetry_exporter.exporters._common import (
|
|
14
|
+
compact_dumps,
|
|
15
|
+
http_send,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OpenSearchAuth(StrEnum):
|
|
20
|
+
"""Authentication method for the index request."""
|
|
21
|
+
|
|
22
|
+
# IAM credentials, SigV4-signed (Amazon OpenSearch Service)
|
|
23
|
+
SIGV4 = "sigv4"
|
|
24
|
+
# username / password (self-managed, or a domain with basic auth)
|
|
25
|
+
BASIC = "basic"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Accepted string inputs, kept in lockstep with the enum values above.
|
|
29
|
+
OpenSearchAuthInput = Literal["sigv4", "basic"]
|
|
30
|
+
|
|
31
|
+
# Same character set that browsers leave unescaped in URI components.
|
|
32
|
+
_DOC_ID_SAFE = "-_.!~*'()"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OpenSearchExporter:
|
|
36
|
+
"""Indexes each record as one document, keyed by ``executionArn``.
|
|
37
|
+
|
|
38
|
+
A later export for the same execution overwrites the document. Supports
|
|
39
|
+
SigV4 (IAM) and basic authentication; no OpenSearch client library needed.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
endpoint: str,
|
|
45
|
+
region: str,
|
|
46
|
+
index_name: str = "durable-execution-telemetry",
|
|
47
|
+
auth: OpenSearchAuth | OpenSearchAuthInput = OpenSearchAuth.SIGV4,
|
|
48
|
+
username: str | None = None,
|
|
49
|
+
password: str | None = None,
|
|
50
|
+
max_record_size_bytes: int | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
|
53
|
+
self.index_name = index_name
|
|
54
|
+
self.region = region
|
|
55
|
+
self.auth = OpenSearchAuth(auth)
|
|
56
|
+
if self.auth == OpenSearchAuth.BASIC and (username is None or password is None):
|
|
57
|
+
msg = "OpenSearchExporter: auth='basic' requires username and password."
|
|
58
|
+
raise ValueError(msg)
|
|
59
|
+
self.username = username
|
|
60
|
+
self.password = password
|
|
61
|
+
self.max_record_size_bytes: int | None = (
|
|
62
|
+
10_000_000 if max_record_size_bytes is None else max_record_size_bytes
|
|
63
|
+
)
|
|
64
|
+
self._credentials: Any = None
|
|
65
|
+
|
|
66
|
+
def render(self, record: dict[str, Any]) -> dict[str, Any]:
|
|
67
|
+
return record
|
|
68
|
+
|
|
69
|
+
def export(self, record: dict[str, Any]) -> None:
|
|
70
|
+
doc_id = quote(record["executionArn"], safe=_DOC_ID_SAFE)
|
|
71
|
+
url = f"{self.endpoint}/{self.index_name}/_doc/{doc_id}"
|
|
72
|
+
body = compact_dumps(record).encode("utf-8")
|
|
73
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
74
|
+
|
|
75
|
+
if self.auth == OpenSearchAuth.BASIC:
|
|
76
|
+
token = base64.b64encode(
|
|
77
|
+
f"{self.username}:{self.password}".encode()
|
|
78
|
+
).decode("ascii")
|
|
79
|
+
headers["Authorization"] = f"Basic {token}"
|
|
80
|
+
else:
|
|
81
|
+
# The signed header set is sent as-is: it already carries
|
|
82
|
+
# content-type, host, date, token and authorization.
|
|
83
|
+
headers = self._sign(url, body, headers)
|
|
84
|
+
|
|
85
|
+
status, reason, detail = http_send("PUT", url, headers, body)
|
|
86
|
+
if not 200 <= status < 300:
|
|
87
|
+
msg = f"OpenSearch index failed: {status} {reason}"
|
|
88
|
+
if detail:
|
|
89
|
+
msg += f" — {detail[:500]}"
|
|
90
|
+
raise RuntimeError(msg)
|
|
91
|
+
|
|
92
|
+
def flush(self) -> None:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def _sign(self, url: str, body: bytes, headers: dict[str, str]) -> dict[str, str]:
|
|
96
|
+
# deferred: botocore is provided by the Lambda runtime
|
|
97
|
+
from botocore.auth import SigV4Auth
|
|
98
|
+
from botocore.awsrequest import AWSRequest
|
|
99
|
+
|
|
100
|
+
if self._credentials is None:
|
|
101
|
+
import botocore.session
|
|
102
|
+
|
|
103
|
+
self._credentials = botocore.session.get_session().get_credentials()
|
|
104
|
+
if self._credentials is None:
|
|
105
|
+
msg = "OpenSearchExporter: no AWS credentials found for SigV4 signing."
|
|
106
|
+
raise RuntimeError(msg)
|
|
107
|
+
request = AWSRequest(method="PUT", url=url, data=body, headers=headers)
|
|
108
|
+
SigV4Auth(self._credentials, "es", self.region).add_auth(request)
|
|
109
|
+
return dict(request.headers.items())
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present Amazon.com, Inc. or its affiliates.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""OpenTelemetry (OTLP/HTTP logs) Durable Execution Telemetry exporter."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from aws_durable_execution_sdk_python_telemetry_exporter.exporters._common import (
|
|
13
|
+
compact_dumps,
|
|
14
|
+
http_send,
|
|
15
|
+
parse_iso_datetime,
|
|
16
|
+
)
|
|
17
|
+
from aws_durable_execution_sdk_python_telemetry_exporter.operations_index import (
|
|
18
|
+
OperationsFormat,
|
|
19
|
+
OperationsFormatInput,
|
|
20
|
+
apply_operations_format,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OTelProtocol(StrEnum):
|
|
25
|
+
"""OTLP transport encoding."""
|
|
26
|
+
|
|
27
|
+
HTTP_JSON = "http/json"
|
|
28
|
+
HTTP_PROTOBUF = "http/protobuf"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Accepted string inputs, kept in lockstep with the enum values above.
|
|
32
|
+
OTelProtocolInput = Literal["http/json", "http/protobuf"]
|
|
33
|
+
|
|
34
|
+
_SCOPE_NAME = "aws-durable-execution-sdk-python-telemetry-exporter"
|
|
35
|
+
_SEVERITY = {"FAILED": 17, "RUNNING": 9, "SUCCEEDED": 9} # ERROR / INFO / INFO
|
|
36
|
+
_EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _kv(key: str, value: str | int) -> dict[str, Any]:
|
|
40
|
+
if isinstance(value, int):
|
|
41
|
+
return {"key": key, "value": {"intValue": str(value)}}
|
|
42
|
+
return {"key": key, "value": {"stringValue": value}}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _to_nano(iso: str) -> str:
|
|
46
|
+
# Keep the record's full microsecond precision (integer math, no float).
|
|
47
|
+
delta = parse_iso_datetime(iso) - _EPOCH
|
|
48
|
+
seconds = delta.days * 86_400 + delta.seconds
|
|
49
|
+
return str(seconds * 1_000_000_000 + delta.microseconds * 1_000)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class OTelExporter:
|
|
53
|
+
"""Posts each record as one OTLP log record to an OTLP/HTTP endpoint.
|
|
54
|
+
|
|
55
|
+
Record identity fields become resource and log attributes; the record
|
|
56
|
+
itself, rendered per ``operations_format``, is the log body. Only
|
|
57
|
+
``http/json`` is supported; ``http/protobuf`` raises at construction.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
endpoint: str,
|
|
63
|
+
headers: dict[str, str] | None = None,
|
|
64
|
+
protocol: OTelProtocol | OTelProtocolInput = OTelProtocol.HTTP_JSON,
|
|
65
|
+
operations_format: OperationsFormat | OperationsFormatInput = (
|
|
66
|
+
OperationsFormat.ARRAY
|
|
67
|
+
),
|
|
68
|
+
max_record_size_bytes: int | None = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
if OTelProtocol(protocol) == OTelProtocol.HTTP_PROTOBUF:
|
|
71
|
+
msg = "OTelExporter: http/protobuf is not yet supported. Use http/json."
|
|
72
|
+
raise ValueError(msg)
|
|
73
|
+
self.endpoint = endpoint
|
|
74
|
+
self.headers = dict(headers or {})
|
|
75
|
+
self.operations_format = OperationsFormat(operations_format)
|
|
76
|
+
self.max_record_size_bytes: int | None = (
|
|
77
|
+
1_000_000 if max_record_size_bytes is None else max_record_size_bytes
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def render(self, record: dict[str, Any]) -> dict[str, Any]:
|
|
81
|
+
# The whole request is measured: the record sits inside body.stringValue,
|
|
82
|
+
# so the size limit covers both the record and the OTLP envelope.
|
|
83
|
+
return self._build_payload(record)
|
|
84
|
+
|
|
85
|
+
def export(self, record: dict[str, Any]) -> None:
|
|
86
|
+
body = compact_dumps(self._build_payload(record)).encode("utf-8")
|
|
87
|
+
headers = {"Content-Type": "application/json", **self.headers}
|
|
88
|
+
status, reason, _ = http_send("POST", self.endpoint, headers, body)
|
|
89
|
+
if not 200 <= status < 300:
|
|
90
|
+
msg = f"OTelExporter: OTLP endpoint returned {status} {reason}"
|
|
91
|
+
raise RuntimeError(msg)
|
|
92
|
+
|
|
93
|
+
def flush(self) -> None:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def _build_payload(self, record: dict[str, Any]) -> dict[str, Any]:
|
|
97
|
+
"""Build an OTLP ``ExportLogsServiceRequest``."""
|
|
98
|
+
function_name = record.get("functionName", "")
|
|
99
|
+
status = record.get("status", "")
|
|
100
|
+
return {
|
|
101
|
+
"resourceLogs": [
|
|
102
|
+
{
|
|
103
|
+
"resource": {
|
|
104
|
+
"attributes": [
|
|
105
|
+
_kv("service.name", function_name),
|
|
106
|
+
_kv("cloud.region", record.get("region", "")),
|
|
107
|
+
_kv("cloud.account.id", record.get("accountId", "")),
|
|
108
|
+
_kv("faas.name", function_name),
|
|
109
|
+
_kv("faas.version", record.get("functionQualifier", "")),
|
|
110
|
+
]
|
|
111
|
+
},
|
|
112
|
+
"scopeLogs": [
|
|
113
|
+
{
|
|
114
|
+
"scope": {
|
|
115
|
+
"name": _SCOPE_NAME,
|
|
116
|
+
"version": record.get("schemaVersion", ""),
|
|
117
|
+
},
|
|
118
|
+
"logRecords": [
|
|
119
|
+
{
|
|
120
|
+
"timeUnixNano": _to_nano(record["emittedAt"]),
|
|
121
|
+
"severityNumber": _SEVERITY.get(status, 0),
|
|
122
|
+
"severityText": status,
|
|
123
|
+
"body": {
|
|
124
|
+
"stringValue": compact_dumps(
|
|
125
|
+
apply_operations_format(
|
|
126
|
+
record, self.operations_format
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
},
|
|
130
|
+
"attributes": [
|
|
131
|
+
_kv(
|
|
132
|
+
"workflow.execution_arn",
|
|
133
|
+
record["executionArn"],
|
|
134
|
+
),
|
|
135
|
+
_kv(
|
|
136
|
+
"workflow.execution_name",
|
|
137
|
+
record.get("executionName") or "",
|
|
138
|
+
),
|
|
139
|
+
_kv("workflow.status", status),
|
|
140
|
+
_kv(
|
|
141
|
+
"workflow.duration_ms",
|
|
142
|
+
record.get("durationMs") or 0,
|
|
143
|
+
),
|
|
144
|
+
],
|
|
145
|
+
}
|
|
146
|
+
],
|
|
147
|
+
}
|
|
148
|
+
],
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present Amazon.com, Inc. or its affiliates.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Redshift (Data API) Durable Execution Telemetry exporter."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from aws_durable_execution_sdk_python_telemetry_exporter.exporters._common import (
|
|
11
|
+
compact_dumps,
|
|
12
|
+
sql_identifier,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_COLUMNS = (
|
|
17
|
+
"execution_arn, execution_name, function_name, status, start_time, "
|
|
18
|
+
"end_time, duration_ms, record_json, emitted_at"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RedshiftExporter:
|
|
23
|
+
"""Upserts one row per execution through the Redshift Data API.
|
|
24
|
+
|
|
25
|
+
Works with Redshift Serverless (``workgroup_name``) and provisioned
|
|
26
|
+
clusters (``cluster_identifier``). Rows are merged on ``execution_arn`` and
|
|
27
|
+
the full record lands in the ``record_json`` SUPER column. The statement is
|
|
28
|
+
submitted and not awaited.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
database: str,
|
|
34
|
+
workgroup_name: str | None = None,
|
|
35
|
+
cluster_identifier: str | None = None,
|
|
36
|
+
db_user: str | None = None,
|
|
37
|
+
secret_arn: str | None = None,
|
|
38
|
+
table: str = "durable_execution_telemetry",
|
|
39
|
+
schema: str = "public",
|
|
40
|
+
region: str | None = None,
|
|
41
|
+
max_record_size_bytes: int | None = None,
|
|
42
|
+
client: Any = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
if bool(workgroup_name) == bool(cluster_identifier):
|
|
45
|
+
msg = (
|
|
46
|
+
"RedshiftExporter: provide exactly one of workgroup_name or "
|
|
47
|
+
"cluster_identifier."
|
|
48
|
+
)
|
|
49
|
+
raise ValueError(msg)
|
|
50
|
+
self.database = database
|
|
51
|
+
self.fq_table = f"{sql_identifier(schema)}.{sql_identifier(table)}"
|
|
52
|
+
self.workgroup_name = workgroup_name
|
|
53
|
+
self.cluster_identifier = cluster_identifier
|
|
54
|
+
self.db_user = db_user
|
|
55
|
+
self.secret_arn = secret_arn
|
|
56
|
+
self.max_record_size_bytes: int | None = (
|
|
57
|
+
1_000_000 if max_record_size_bytes is None else max_record_size_bytes
|
|
58
|
+
)
|
|
59
|
+
if client is not None:
|
|
60
|
+
self._client = client
|
|
61
|
+
else:
|
|
62
|
+
import boto3 # deferred: boto3 is provided by the Lambda runtime
|
|
63
|
+
|
|
64
|
+
self._client = (
|
|
65
|
+
boto3.client("redshift-data", region_name=region)
|
|
66
|
+
if region
|
|
67
|
+
else boto3.client("redshift-data")
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def render(self, record: dict[str, Any]) -> dict[str, Any]:
|
|
71
|
+
return record
|
|
72
|
+
|
|
73
|
+
def export(self, record: dict[str, Any]) -> None:
|
|
74
|
+
parameters: list[dict[str, str]] = [
|
|
75
|
+
{"name": "execution_arn", "value": record["executionArn"]},
|
|
76
|
+
{"name": "function_name", "value": record["functionName"]},
|
|
77
|
+
{"name": "status", "value": record["status"]},
|
|
78
|
+
{"name": "start_time", "value": record["startTime"]},
|
|
79
|
+
{"name": "record_json", "value": compact_dumps(record)},
|
|
80
|
+
{"name": "emitted_at", "value": record["emittedAt"]},
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
# The Data API takes neither NULL nor empty-string parameter values, so
|
|
84
|
+
# an absent nullable field becomes a typed NULL literal in the source
|
|
85
|
+
# projection instead of a bound parameter.
|
|
86
|
+
if record.get("endTime"):
|
|
87
|
+
parameters.append({"name": "end_time", "value": record["endTime"]})
|
|
88
|
+
end_time_sel = ":end_time::timestamptz"
|
|
89
|
+
else:
|
|
90
|
+
end_time_sel = "NULL::timestamptz"
|
|
91
|
+
if record.get("durationMs") is not None:
|
|
92
|
+
parameters.append(
|
|
93
|
+
{"name": "duration_ms", "value": str(record["durationMs"])}
|
|
94
|
+
)
|
|
95
|
+
duration_sel = ":duration_ms::bigint"
|
|
96
|
+
else:
|
|
97
|
+
duration_sel = "NULL::bigint"
|
|
98
|
+
if record.get("executionName"):
|
|
99
|
+
parameters.append(
|
|
100
|
+
{"name": "execution_name", "value": record["executionName"]}
|
|
101
|
+
)
|
|
102
|
+
exec_name_sel = ":execution_name::varchar"
|
|
103
|
+
else:
|
|
104
|
+
exec_name_sel = "NULL::varchar"
|
|
105
|
+
|
|
106
|
+
# The source row is a subquery and MERGE joins on a source column:
|
|
107
|
+
# Redshift rejects a MERGE whose join is on a parameter or constant
|
|
108
|
+
# (NestedLoop). Time columns are cast to timestamptz and record_json is
|
|
109
|
+
# JSON_PARSEd into the SUPER column.
|
|
110
|
+
sql = (
|
|
111
|
+
f"MERGE INTO {self.fq_table} USING (\n"
|
|
112
|
+
" SELECT\n"
|
|
113
|
+
" :execution_arn::varchar AS execution_arn,\n"
|
|
114
|
+
f" {exec_name_sel} AS execution_name,\n"
|
|
115
|
+
" :function_name::varchar AS function_name,\n"
|
|
116
|
+
" :status::varchar AS status,\n"
|
|
117
|
+
" :start_time::timestamptz AS start_time,\n"
|
|
118
|
+
f" {end_time_sel} AS end_time,\n"
|
|
119
|
+
f" {duration_sel} AS duration_ms,\n"
|
|
120
|
+
" JSON_PARSE(:record_json) AS record_json,\n"
|
|
121
|
+
" :emitted_at::timestamptz AS emitted_at\n"
|
|
122
|
+
" ) AS src\n"
|
|
123
|
+
f" ON {self.fq_table}.execution_arn = src.execution_arn\n"
|
|
124
|
+
" WHEN MATCHED THEN UPDATE SET\n"
|
|
125
|
+
" status = src.status,\n"
|
|
126
|
+
" end_time = src.end_time,\n"
|
|
127
|
+
" duration_ms = src.duration_ms,\n"
|
|
128
|
+
" record_json = src.record_json,\n"
|
|
129
|
+
" emitted_at = src.emitted_at\n"
|
|
130
|
+
" WHEN NOT MATCHED THEN INSERT\n"
|
|
131
|
+
f" ({_COLUMNS})\n"
|
|
132
|
+
" VALUES\n"
|
|
133
|
+
" (src.execution_arn, src.execution_name, src.function_name, "
|
|
134
|
+
"src.status, src.start_time, src.end_time, src.duration_ms, "
|
|
135
|
+
"src.record_json, src.emitted_at)"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
kwargs: dict[str, Any] = {
|
|
139
|
+
"Database": self.database,
|
|
140
|
+
"Sql": sql,
|
|
141
|
+
"Parameters": parameters,
|
|
142
|
+
}
|
|
143
|
+
# Unset optional targets are omitted rather than passed as None.
|
|
144
|
+
if self.workgroup_name:
|
|
145
|
+
kwargs["WorkgroupName"] = self.workgroup_name
|
|
146
|
+
if self.cluster_identifier:
|
|
147
|
+
kwargs["ClusterIdentifier"] = self.cluster_identifier
|
|
148
|
+
if self.db_user:
|
|
149
|
+
kwargs["DbUser"] = self.db_user
|
|
150
|
+
if self.secret_arn:
|
|
151
|
+
kwargs["SecretArn"] = self.secret_arn
|
|
152
|
+
self._client.execute_statement(**kwargs)
|
|
153
|
+
|
|
154
|
+
def flush(self) -> None:
|
|
155
|
+
return None
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present Amazon.com, Inc. or its affiliates.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""S3 Durable Execution Telemetry exporter."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from aws_durable_execution_sdk_python_telemetry_exporter.exporters._common import (
|
|
12
|
+
compact_dumps,
|
|
13
|
+
sanitize,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class S3Partitioning(StrEnum):
|
|
18
|
+
"""S3 key partitioning scheme. The values match the JS S3 exporter's options."""
|
|
19
|
+
|
|
20
|
+
# year=YYYY/month=MM/day=DD/ derived from the record startTime (default)
|
|
21
|
+
DATE = "date"
|
|
22
|
+
# function=<functionName>/
|
|
23
|
+
FUNCTION_NAME = "function-name"
|
|
24
|
+
# no partition prefix
|
|
25
|
+
NONE = "none"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Accepted string inputs, kept in lockstep with the enum values above. The
|
|
29
|
+
# constructor is typed as this ``Literal`` union (never bare ``str``) so a typoed
|
|
30
|
+
# scheme fails a static type check, while ``S3Partitioning(partitioning)`` in
|
|
31
|
+
# ``__init__`` normalizes any accepted value to the matching enum member and
|
|
32
|
+
# raises ``ValueError`` for an invalid dynamic string.
|
|
33
|
+
S3PartitioningInput = Literal["date", "function-name", "none"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class S3Exporter:
|
|
37
|
+
"""Writes canonical ``operations``-array records to S3.
|
|
38
|
+
|
|
39
|
+
Port of the JS ``S3Exporter``. Each record is a JSON object keyed by
|
|
40
|
+
execution name, so updates to the same execution overwrite the same object.
|
|
41
|
+
Emits the lossless ``operations`` array (``OPERATIONS_ARRAY``).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
bucket: str,
|
|
47
|
+
prefix: str = "durable-execution-telemetry/",
|
|
48
|
+
partitioning: S3Partitioning | S3PartitioningInput = S3Partitioning.DATE,
|
|
49
|
+
region: str | None = None,
|
|
50
|
+
max_record_size_bytes: int | None = None,
|
|
51
|
+
client: Any = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.bucket = bucket
|
|
54
|
+
self.prefix = prefix
|
|
55
|
+
# ``S3Partitioning(x)`` is idempotent for members, accepts the exact
|
|
56
|
+
# JS-style strings, and raises ``ValueError`` for an unrecognized dynamic
|
|
57
|
+
# string so an invalid scheme fails at construction rather than silently
|
|
58
|
+
# falling through to no partitioning.
|
|
59
|
+
self.partitioning = S3Partitioning(partitioning)
|
|
60
|
+
self.max_record_size_bytes: int | None = (
|
|
61
|
+
5_000_000 if max_record_size_bytes is None else max_record_size_bytes
|
|
62
|
+
)
|
|
63
|
+
if client is not None:
|
|
64
|
+
self._client = client
|
|
65
|
+
else:
|
|
66
|
+
import boto3 # deferred: boto3 is provided by the Lambda runtime
|
|
67
|
+
|
|
68
|
+
self._client = (
|
|
69
|
+
boto3.client("s3", region_name=region) if region else boto3.client("s3")
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def render(self, record: dict[str, Any]) -> dict[str, Any]:
|
|
73
|
+
return record
|
|
74
|
+
|
|
75
|
+
def export(self, record: dict[str, Any]) -> None:
|
|
76
|
+
key = self._build_key(record)
|
|
77
|
+
self._client.put_object(
|
|
78
|
+
Bucket=self.bucket,
|
|
79
|
+
Key=key,
|
|
80
|
+
Body=compact_dumps(record).encode("utf-8"),
|
|
81
|
+
ContentType="application/json",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def flush(self) -> None:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
def _build_key(self, record: dict[str, Any]) -> str:
|
|
88
|
+
file_name = (
|
|
89
|
+
sanitize(
|
|
90
|
+
record.get("executionName") or record.get("executionArn") or "record"
|
|
91
|
+
)
|
|
92
|
+
+ ".json"
|
|
93
|
+
)
|
|
94
|
+
return f"{self.prefix}{self._partition(record)}{file_name}"
|
|
95
|
+
|
|
96
|
+
def _partition(self, record: dict[str, Any]) -> str:
|
|
97
|
+
if self.partitioning == S3Partitioning.FUNCTION_NAME:
|
|
98
|
+
return f"function={sanitize(record.get('functionName', ''))}/"
|
|
99
|
+
if self.partitioning == S3Partitioning.DATE:
|
|
100
|
+
start = str(record.get("startTime", ""))
|
|
101
|
+
# YYYY-MM-DD... -> year=YYYY/month=MM/day=DD/
|
|
102
|
+
if len(start) >= 10 and start[4] == "-" and start[7] == "-":
|
|
103
|
+
return f"year={start[0:4]}/month={start[5:7]}/day={start[8:10]}/"
|
|
104
|
+
return ""
|
|
105
|
+
return ""
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present Amazon.com, Inc. or its affiliates.
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""SQS Durable Execution Telemetry exporter."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from aws_durable_execution_sdk_python_telemetry_exporter.exporters._common import (
|
|
12
|
+
compact_dumps,
|
|
13
|
+
)
|
|
14
|
+
from aws_durable_execution_sdk_python_telemetry_exporter.operations_index import (
|
|
15
|
+
OperationsFormat,
|
|
16
|
+
OperationsFormatInput,
|
|
17
|
+
apply_operations_format,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# SQS caps MessageGroupId and MessageDeduplicationId at 128 characters.
|
|
22
|
+
_MAX_ID_LENGTH = 128
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SQSExporter:
|
|
26
|
+
"""Sends each record as one SQS message with SendMessage.
|
|
27
|
+
|
|
28
|
+
The rendered record is the message body; ``status`` and ``functionName``
|
|
29
|
+
are message attributes. On a FIFO queue (URL ending in ``.fifo``) the group
|
|
30
|
+
id defaults to ``executionArn`` and the deduplication id is
|
|
31
|
+
``executionArn:emittedAt``; an id longer than 128 characters is replaced by
|
|
32
|
+
its SHA-256 hex digest so the message is never rejected.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
queue_url: str,
|
|
38
|
+
message_group_id: str | None = None,
|
|
39
|
+
region: str | None = None,
|
|
40
|
+
operations_format: OperationsFormat | OperationsFormatInput = (
|
|
41
|
+
OperationsFormat.ARRAY
|
|
42
|
+
),
|
|
43
|
+
max_record_size_bytes: int | None = None,
|
|
44
|
+
client: Any = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.queue_url = queue_url
|
|
47
|
+
self.message_group_id = message_group_id
|
|
48
|
+
self.is_fifo = queue_url.endswith(".fifo")
|
|
49
|
+
self.operations_format = OperationsFormat(operations_format)
|
|
50
|
+
self.max_record_size_bytes: int | None = (
|
|
51
|
+
256_000 if max_record_size_bytes is None else max_record_size_bytes
|
|
52
|
+
)
|
|
53
|
+
if client is not None:
|
|
54
|
+
self._client = client
|
|
55
|
+
else:
|
|
56
|
+
import boto3 # deferred: boto3 is provided by the Lambda runtime
|
|
57
|
+
|
|
58
|
+
self._client = (
|
|
59
|
+
boto3.client("sqs", region_name=region)
|
|
60
|
+
if region
|
|
61
|
+
else boto3.client("sqs")
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def render(self, record: dict[str, Any]) -> dict[str, Any]:
|
|
65
|
+
return apply_operations_format(record, self.operations_format)
|
|
66
|
+
|
|
67
|
+
def export(self, record: dict[str, Any]) -> None:
|
|
68
|
+
kwargs: dict[str, Any] = {
|
|
69
|
+
"QueueUrl": self.queue_url,
|
|
70
|
+
"MessageBody": compact_dumps(self.render(record)),
|
|
71
|
+
"MessageAttributes": {
|
|
72
|
+
"status": {"DataType": "String", "StringValue": record["status"]},
|
|
73
|
+
"functionName": {
|
|
74
|
+
"DataType": "String",
|
|
75
|
+
"StringValue": record["functionName"],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
if self.is_fifo:
|
|
80
|
+
kwargs["MessageGroupId"] = _bounded_id(
|
|
81
|
+
self.message_group_id or record["executionArn"]
|
|
82
|
+
)
|
|
83
|
+
kwargs["MessageDeduplicationId"] = _bounded_id(
|
|
84
|
+
f"{record['executionArn']}:{record['emittedAt']}"
|
|
85
|
+
)
|
|
86
|
+
self._client.send_message(**kwargs)
|
|
87
|
+
|
|
88
|
+
def flush(self) -> None:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _bounded_id(value: str) -> str:
|
|
93
|
+
"""Return ``value`` if it fits the SQS 128-character id limit, else its SHA-256."""
|
|
94
|
+
if len(value) <= _MAX_ID_LENGTH:
|
|
95
|
+
return value
|
|
96
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|