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,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()