log-foundry 0.0.2.dev1__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 (51) hide show
  1. log_forge/__init__.py +45 -0
  2. log_forge/api.py +89 -0
  3. log_forge/config.py +79 -0
  4. log_forge/console.py +30 -0
  5. log_forge/context.py +62 -0
  6. log_forge/decorator.py +157 -0
  7. log_forge/ids.py +29 -0
  8. log_forge/model.py +106 -0
  9. log_forge/py.typed +0 -0
  10. log_forge/sinks/__init__.py +0 -0
  11. log_forge/sinks/_chunk.py +58 -0
  12. log_forge/sinks/_socket.py +105 -0
  13. log_forge/sinks/_time.py +28 -0
  14. log_forge/sinks/base.py +22 -0
  15. log_forge/sinks/callback.py +42 -0
  16. log_forge/sinks/clickhouse.py +111 -0
  17. log_forge/sinks/datadog.py +53 -0
  18. log_forge/sinks/elasticsearch.py +68 -0
  19. log_forge/sinks/eventhubs.py +110 -0
  20. log_forge/sinks/file.py +177 -0
  21. log_forge/sinks/filtering.py +71 -0
  22. log_forge/sinks/firehose.py +91 -0
  23. log_forge/sinks/honeycomb.py +38 -0
  24. log_forge/sinks/http.py +202 -0
  25. log_forge/sinks/kafka.py +71 -0
  26. log_forge/sinks/kinesis.py +99 -0
  27. log_forge/sinks/logging_sink.py +104 -0
  28. log_forge/sinks/logstash.py +73 -0
  29. log_forge/sinks/loki.py +48 -0
  30. log_forge/sinks/mongodb.py +105 -0
  31. log_forge/sinks/multi.py +53 -0
  32. log_forge/sinks/nats.py +72 -0
  33. log_forge/sinks/newrelic.py +28 -0
  34. log_forge/sinks/postgres.py +101 -0
  35. log_forge/sinks/pubsub.py +45 -0
  36. log_forge/sinks/rabbitmq.py +129 -0
  37. log_forge/sinks/redis.py +90 -0
  38. log_forge/sinks/sentry.py +136 -0
  39. log_forge/sinks/sns.py +82 -0
  40. log_forge/sinks/splunk.py +51 -0
  41. log_forge/sinks/sqlite.py +100 -0
  42. log_forge/sinks/sqs.py +107 -0
  43. log_forge/sinks/stdout.py +31 -0
  44. log_forge/sinks/syslog.py +88 -0
  45. log_forge/sinks/transform.py +46 -0
  46. log_forge/sinks/util.py +68 -0
  47. log_forge/worker.py +153 -0
  48. log_foundry-0.0.2.dev1.dist-info/METADATA +537 -0
  49. log_foundry-0.0.2.dev1.dist-info/RECORD +51 -0
  50. log_foundry-0.0.2.dev1.dist-info/WHEEL +4 -0
  51. log_foundry-0.0.2.dev1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,105 @@
1
+ """Shared raw-socket transport for LogstashSink (socket mode) and SyslogSink (SPEC-009).
2
+
3
+ Sends pre-framed byte messages over TCP or UDP with bounded reconnect retry. For TCP a single
4
+ connection is opened and reused (reconnecting on error); for UDP each message is an independent
5
+ datagram. The transport is framing-agnostic — callers hand it the exact bytes to put on the wire
6
+ (newline-terminated JSON for Logstash, RFC 5424 / octet-counted frames for Syslog).
7
+
8
+ Socket creation goes through the module-level ``_make_tcp`` / ``_make_udp`` seams so tests can
9
+ substitute a fake socket without any network access.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import socket
15
+ import sys
16
+ import time
17
+
18
+ __all__ = ["SocketTransport"]
19
+
20
+ _BACKOFF_BASE = 0.1 # seconds; delay for retry attempt n is _BACKOFF_BASE * 2**n
21
+
22
+
23
+ def _make_tcp(host: str, port: int, timeout: float) -> socket.socket:
24
+ """Open a connected TCP socket (indirection seam for tests)."""
25
+ return socket.create_connection((host, port), timeout=timeout)
26
+
27
+
28
+ def _make_udp() -> socket.socket:
29
+ """Open an unconnected UDP socket (indirection seam for tests)."""
30
+ return socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
31
+
32
+
33
+ class SocketTransport:
34
+ """Send pre-framed messages over a TCP or UDP socket, reconnecting on error within a bound.
35
+
36
+ Attributes:
37
+ failed: Messages abandoned past the reconnect-retry bound.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ host: str,
43
+ port: int,
44
+ *,
45
+ transport: str = "tcp",
46
+ timeout: float = 5.0,
47
+ max_retries: int = 3,
48
+ ) -> None:
49
+ if transport not in ("tcp", "udp"):
50
+ raise ValueError(f"invalid transport {transport!r}; expected 'tcp' or 'udp'")
51
+ self._host = host
52
+ self._port = port
53
+ self._transport = transport
54
+ self._timeout = timeout
55
+ self._max_retries = max_retries
56
+ self._sock: socket.socket | None = None
57
+ self.failed = 0
58
+
59
+ def send_all(self, messages: list[bytes]) -> None:
60
+ """Send each pre-framed message, reconnecting on error (FR-005, FR-006)."""
61
+ for message in messages:
62
+ self._send_one(message)
63
+
64
+ def close(self) -> None:
65
+ """Close the held socket, if any; idempotent (FR-005, FR-012)."""
66
+ self._reset()
67
+
68
+ # -- internals ----------------------------------------------------------------------
69
+
70
+ def _send_one(self, message: bytes) -> None:
71
+ for attempt in range(self._max_retries + 1):
72
+ try:
73
+ if self._transport == "udp":
74
+ self._socket().sendto(message, (self._host, self._port))
75
+ else:
76
+ self._socket().sendall(message)
77
+ return
78
+ except OSError as err:
79
+ self._reset() # force a fresh connection on the next attempt
80
+ if attempt < self._max_retries:
81
+ time.sleep(_BACKOFF_BASE * (2**attempt))
82
+ continue
83
+ self.failed += 1
84
+ sys.stderr.write(
85
+ f"log-forge: SocketTransport abandoned a message after "
86
+ f"{self._max_retries + 1} attempt(s) ({err!r})\n"
87
+ )
88
+ return
89
+
90
+ def _socket(self) -> socket.socket:
91
+ if self._sock is None:
92
+ self._sock = (
93
+ _make_udp() if self._transport == "udp" else _make_tcp(
94
+ self._host, self._port, self._timeout
95
+ )
96
+ )
97
+ return self._sock
98
+
99
+ def _reset(self) -> None:
100
+ if self._sock is not None:
101
+ try:
102
+ self._sock.close()
103
+ except OSError:
104
+ pass
105
+ self._sock = None
@@ -0,0 +1,28 @@
1
+ """ISO-8601 → epoch helpers for sinks that need numeric time (SPEC-009).
2
+
3
+ A SPEC-001 ``LogEvent``'s ``timestamp`` is an ISO-8601 *string* (``2026-07-11T00:00:00.123Z``), not
4
+ an epoch. Sinks like Loki (nanoseconds) and Splunk HEC (epoch seconds) derive numeric time by
5
+ parsing that string, falling back to emit-time ``now`` when it is absent or unparseable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from datetime import datetime
12
+
13
+ __all__ = ["epoch_seconds", "epoch_nanos"]
14
+
15
+
16
+ def epoch_seconds(timestamp: object) -> float:
17
+ """Epoch seconds from a SPEC-001 ISO-8601 string; emit-time ``now`` if absent/unparseable."""
18
+ if isinstance(timestamp, str):
19
+ try:
20
+ return datetime.fromisoformat(timestamp).timestamp()
21
+ except ValueError:
22
+ pass
23
+ return time.time()
24
+
25
+
26
+ def epoch_nanos(timestamp: object) -> int:
27
+ """Epoch nanoseconds (as required by Loki), derived from :func:`epoch_seconds`."""
28
+ return int(epoch_seconds(timestamp) * 1_000_000_000)
@@ -0,0 +1,22 @@
1
+ """The sink interface (arch §8).
2
+
3
+ A sink is the swappable output transport. It receives *already-built, batched* event
4
+ dicts from the worker and knows nothing about spans or context — that dumbness is what
5
+ makes sinks trivially interchangeable (StdoutSink, SQSSink, …). Only the Protocol lives
6
+ here; concrete sinks arrive in later phases.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Protocol, runtime_checkable
12
+
13
+
14
+ @runtime_checkable
15
+ class Sink(Protocol):
16
+ def emit(self, batch: list[dict[str, object]]) -> None:
17
+ """Ship a batch of serialized event dicts."""
18
+ ...
19
+
20
+ def close(self) -> None:
21
+ """Flush and release any resources."""
22
+ ...
@@ -0,0 +1,42 @@
1
+ """CallbackSink — turn a plain callable into a Sink (arch §8, SPEC-006 FR-001).
2
+
3
+ The ultimate escape hatch: point log-forge at any destination by handing it a function,
4
+ without writing a ``Sink`` subclass. Like every sink it receives *already-built* event dicts
5
+ and knows nothing about spans or context — that dumbness is what makes sinks swappable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+
12
+ __all__ = ["CallbackSink"]
13
+
14
+
15
+ class CallbackSink:
16
+ """A :class:`~log_forge.sinks.base.Sink` that delegates each batch to a callable.
17
+
18
+ Attributes are internal; the observable contract is that ``emit`` hands the batch to
19
+ ``fn`` unchanged and ``close`` invokes ``on_close`` once when one was supplied.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ fn: Callable[[list[dict[str, object]]], None],
25
+ *,
26
+ on_close: Callable[[], None] | None = None,
27
+ ) -> None:
28
+ self._fn = fn
29
+ self._on_close = on_close
30
+
31
+ def emit(self, batch: list[dict[str, object]]) -> None:
32
+ """Hand ``batch`` to the callable, unchanged and exactly once (FR-001).
33
+
34
+ Any exception the callable raises propagates out — the worker's retry/backoff path
35
+ handles it; this sink never swallows it.
36
+ """
37
+ self._fn(batch)
38
+
39
+ def close(self) -> None:
40
+ """Call ``on_close`` once if one was supplied, otherwise a no-op (FR-001)."""
41
+ if self._on_close is not None:
42
+ self._on_close()
@@ -0,0 +1,111 @@
1
+ """ClickHouseSink — batch-insert events into a ClickHouse table (arch §8, SPEC-011).
2
+
3
+ ClickHouse is the columnar, observability-scale favorite. ``clickhouse-connect`` is the optional
4
+ ``clickhouse`` extra, imported lazily. Each event maps to a row of extracted typed columns plus the
5
+ full event as a ``String`` column, inserted in a single ``insert`` call per chunk. An optional
6
+ idempotent ``MergeTree`` ``create_table`` convenience is off by default. Write-only.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ import time
14
+ from typing import Any
15
+
16
+ from log_forge.sinks._chunk import chunk_list, valid_identifier
17
+
18
+ __all__ = ["ClickHouseSink"]
19
+
20
+ _BACKOFF_BASE = 0.1
21
+
22
+ # Extracted columns (typed in the MergeTree schema); ``event`` (full JSON String) is stored too.
23
+ _COLUMNS = (
24
+ "timestamp", "level", "trace_id", "span_id", "function", "service", "duration_ms", "status"
25
+ )
26
+ _COLUMN_NAMES = [*_COLUMNS, "event"]
27
+
28
+ # Per-column DDL types for the optional create_table convenience (all nullable to tolerate missing
29
+ # keys — e.g. span-start events have no duration_ms/status).
30
+ _COLUMN_TYPES = {
31
+ "timestamp": "Nullable(String)",
32
+ "level": "Nullable(String)",
33
+ "trace_id": "Nullable(String)",
34
+ "span_id": "Nullable(String)",
35
+ "function": "Nullable(String)",
36
+ "service": "Nullable(String)",
37
+ "duration_ms": "Nullable(Float64)",
38
+ "status": "Nullable(String)",
39
+ }
40
+
41
+
42
+ class ClickHouseSink:
43
+ """A :class:`~log_forge.sinks.base.Sink` that batch-inserts events into a ClickHouse table."""
44
+
45
+ def __init__(
46
+ self,
47
+ table: str,
48
+ *,
49
+ client: Any = None,
50
+ dsn: str | None = None,
51
+ create_table: bool = False,
52
+ chunk_size: int = 1000,
53
+ max_retries: int = 3,
54
+ ) -> None:
55
+ self._table = valid_identifier(table)
56
+ self._chunk_size = chunk_size
57
+ self.max_retries = max_retries
58
+ self.failed = 0
59
+ self._closed = False
60
+ self._owns_client = client is None
61
+ if client is None:
62
+ import clickhouse_connect # type: ignore[import-not-found] # 'clickhouse' extra
63
+
64
+ client = clickhouse_connect.get_client(dsn=dsn)
65
+ self.client = client
66
+ if create_table:
67
+ self._ensure_schema()
68
+
69
+ def emit(self, batch: list[dict[str, object]]) -> None:
70
+ """Insert each chunk as one columnar ``insert`` call, retrying on failure (FR-002)."""
71
+ if not batch:
72
+ return
73
+ for chunk in chunk_list(batch, self._chunk_size):
74
+ rows = [self._row(event) for event in chunk]
75
+ self._insert(rows)
76
+
77
+ def close(self) -> None:
78
+ """Close the client only if the sink owns it; idempotent (FR-005)."""
79
+ if self._closed:
80
+ return
81
+ if self._owns_client:
82
+ self.client.close()
83
+ self._closed = True
84
+
85
+ # -- internals ----------------------------------------------------------------------
86
+
87
+ def _row(self, event: dict[str, object]) -> list[object]:
88
+ return [*(event.get(col) for col in _COLUMNS), json.dumps(event)]
89
+
90
+ def _insert(self, rows: list[list[object]]) -> None:
91
+ for attempt in range(self.max_retries + 1):
92
+ try:
93
+ self.client.insert(self._table, data=rows, column_names=_COLUMN_NAMES)
94
+ return
95
+ except Exception as err: # isolation boundary: never crash the worker (FR-006)
96
+ if attempt < self.max_retries:
97
+ time.sleep(_BACKOFF_BASE * (2**attempt))
98
+ continue
99
+ self.failed += len(rows)
100
+ sys.stderr.write(
101
+ f"log-forge: ClickHouseSink abandoned {len(rows)} row(s) after "
102
+ f"{self.max_retries + 1} attempts ({err!r})\n"
103
+ )
104
+ return
105
+
106
+ def _ensure_schema(self) -> None:
107
+ columns = ", ".join(f"{col} {_COLUMN_TYPES[col]}" for col in _COLUMNS)
108
+ self.client.command(
109
+ f"CREATE TABLE IF NOT EXISTS {self._table} "
110
+ f"({columns}, event String) ENGINE = MergeTree ORDER BY tuple()"
111
+ )
@@ -0,0 +1,53 @@
1
+ """DatadogSink — ship to Datadog's logs intake (arch §8, SPEC-009).
2
+
3
+ An :class:`~log_forge.sinks.http.HTTPSink` specialization: POSTs the batch as a JSON array to the
4
+ region-specific logs intake with a ``DD-API-KEY`` header, enriching each entry with ``ddsource``,
5
+ ``service``, and ``ddtags`` from configuration / the event.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from log_forge.sinks.http import HTTPSink, merge_headers
13
+
14
+ __all__ = ["DatadogSink"]
15
+
16
+ _DDSOURCE = "log-forge"
17
+
18
+
19
+ class DatadogSink(HTTPSink):
20
+ """POST events to Datadog's logs intake (FR-007)."""
21
+
22
+ def __init__(
23
+ self,
24
+ api_key: str,
25
+ *,
26
+ site: str = "datadoghq.com",
27
+ service: str | None = None,
28
+ ddtags: str | None = None,
29
+ **http_kwargs: object,
30
+ ) -> None:
31
+ self._service = service
32
+ self._ddtags = ddtags
33
+ headers = merge_headers({"DD-API-KEY": api_key}, http_kwargs)
34
+ super().__init__(
35
+ f"https://http-intake.logs.{site}/api/v2/logs",
36
+ headers=headers, body_format="json_array", **http_kwargs, # type: ignore[arg-type]
37
+ )
38
+
39
+ def emit(self, batch: list[dict[str, object]]) -> None:
40
+ """POST each event (enriched) as one JSON array (FR-007)."""
41
+ if not batch:
42
+ return
43
+ body = json.dumps([self._entry(event) for event in batch]).encode("utf-8")
44
+ self._send(body, content_type="application/json")
45
+
46
+ def _entry(self, event: dict[str, object]) -> dict[str, object]:
47
+ entry = dict(event)
48
+ entry["ddsource"] = _DDSOURCE
49
+ if self._service is not None:
50
+ entry["service"] = self._service
51
+ if self._ddtags is not None:
52
+ entry["ddtags"] = self._ddtags
53
+ return entry
@@ -0,0 +1,68 @@
1
+ """ElasticsearchSink / OpenSearchSink — index events via the ``_bulk`` API (arch §8, SPEC-009).
2
+
3
+ A thin specialization of :class:`~log_forge.sinks.http.HTTPSink`: each event becomes an action line
4
+ (``{"index": {"_index": target}}``) followed by its source line, POSTed to ``_bulk`` as newline-
5
+ delimited JSON. The bulk response is inspected per item so a partial failure is counted and logged
6
+ without discarding the successfully-indexed items. OpenSearch speaks the same bulk protocol, so
7
+ ``OpenSearchSink`` is a straight reuse (endpoint/auth differ only by configuration).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+
15
+ from log_forge.sinks.http import HTTPSink
16
+
17
+ __all__ = ["ElasticsearchSink", "OpenSearchSink"]
18
+
19
+
20
+ class ElasticsearchSink(HTTPSink):
21
+ """POST events to an Elasticsearch ``_bulk`` endpoint, parsing per-item errors (FR-003).
22
+
23
+ Attributes:
24
+ item_errors: Count of bulk items the server reported as failed (distinct from ``failed``,
25
+ which counts whole requests abandoned past the retry bound).
26
+ """
27
+
28
+ def __init__(self, url: str, *, index: str, auth: str | tuple[str, str] | None = None,
29
+ **http_kwargs: object) -> None:
30
+ self._index = index
31
+ super().__init__(
32
+ url.rstrip("/") + "/_bulk", auth=auth, body_format="ndjson", **http_kwargs # type: ignore[arg-type]
33
+ )
34
+ self.item_errors = 0
35
+
36
+ def emit(self, batch: list[dict[str, object]]) -> None:
37
+ """Build the ``_bulk`` NDJSON payload, POST it, and parse the response items (FR-003)."""
38
+ if not batch:
39
+ return
40
+ lines: list[str] = []
41
+ for event in batch:
42
+ lines.append(json.dumps({"index": {"_index": self._index}}))
43
+ lines.append(json.dumps(event))
44
+ body = ("\n".join(lines) + "\n").encode("utf-8") # bulk must be newline-terminated
45
+ payload = self._send(body, content_type="application/x-ndjson")
46
+ if payload is not None:
47
+ self._parse_bulk_response(payload)
48
+
49
+ def _parse_bulk_response(self, payload: bytes) -> None:
50
+ """Count items the bulk response flagged as errors; a partial failure keeps the rest."""
51
+ try:
52
+ data = json.loads(payload)
53
+ except (ValueError, TypeError):
54
+ return
55
+ if not isinstance(data, dict) or not data.get("errors"):
56
+ return
57
+ errors = 0
58
+ for item in data.get("items", []):
59
+ result: object = next(iter(item.values()), {}) if isinstance(item, dict) else {}
60
+ if isinstance(result, dict) and result.get("error"):
61
+ errors += 1
62
+ if errors:
63
+ self.item_errors += errors
64
+ sys.stderr.write(f"log-forge: ElasticsearchSink saw {errors} failed bulk item(s)\n")
65
+
66
+
67
+ class OpenSearchSink(ElasticsearchSink):
68
+ """OpenSearch reuses the Elasticsearch ``_bulk`` protocol verbatim (FR-003)."""
@@ -0,0 +1,110 @@
1
+ """AzureEventHubsSink — send events to an Azure Event Hub (arch §8, §9.1, SPEC-010).
2
+
3
+ A durable-buffer sink on ``azure-eventhub`` (the optional ``azure-eventhubs`` extra, imported
4
+ lazily). Events are packed into one or more ``EventDataBatch`` objects respecting the 1 MB per-batch
5
+ limit — the SDK signals a full batch by raising ``ValueError`` from ``batch.add`` — and each full
6
+ batch is sent. A single event too large for an even-empty batch is dropped with a counted warning;
7
+ send errors are retried within a bound. ``close()`` closes the producer.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ import time
15
+ from typing import Any
16
+
17
+ __all__ = ["AzureEventHubsSink"]
18
+
19
+ _BACKOFF_BASE = 0.1
20
+
21
+
22
+ class AzureEventHubsSink:
23
+ """A :class:`~log_forge.sinks.base.Sink` that sends events to an Azure Event Hub."""
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ producer: Any = None,
29
+ connection_str: str | None = None,
30
+ eventhub: str | None = None,
31
+ max_retries: int = 3,
32
+ ) -> None:
33
+ if producer is None:
34
+ if connection_str is None:
35
+ raise ValueError(
36
+ "AzureEventHubsSink requires connection_str when no producer is injected"
37
+ )
38
+ from azure.eventhub import ( # type: ignore[import-not-found] # 'azure-eventhubs'
39
+ EventHubProducerClient,
40
+ )
41
+
42
+ producer = EventHubProducerClient.from_connection_string(
43
+ connection_str, eventhub_name=eventhub
44
+ )
45
+ self.producer = producer
46
+ self.max_retries = max_retries
47
+ self.failed = 0
48
+ self.dropped_oversized = 0
49
+
50
+ def emit(self, batch: list[dict[str, object]]) -> None:
51
+ """Pack events into ≤ 1 MB EventDataBatches and send each; drop oversized events (FR-009)."""
52
+ if not batch:
53
+ return
54
+ event_data_cls = _event_data_cls()
55
+ current = self.producer.create_batch()
56
+ for event in batch:
57
+ data = event_data_cls(json.dumps(event).encode("utf-8"))
58
+ if _try_add(current, data):
59
+ continue
60
+ # current batch is full: send it and start a fresh one for this event.
61
+ self._send(current)
62
+ current = self.producer.create_batch()
63
+ if not _try_add(current, data):
64
+ self.dropped_oversized += 1
65
+ sys.stderr.write(
66
+ "log-forge: AzureEventHubsSink dropped an event too large for an "
67
+ "empty 1 MB batch\n"
68
+ )
69
+ self._send(current)
70
+
71
+ def close(self) -> None:
72
+ """Close the producer (FR-009)."""
73
+ self.producer.close()
74
+
75
+ # -- internals ----------------------------------------------------------------------
76
+
77
+ def _send(self, event_batch: Any) -> None:
78
+ """Send one EventDataBatch (skipping an empty one), retrying failures (FR-009, FR-011)."""
79
+ if len(event_batch) == 0:
80
+ return
81
+ for attempt in range(self.max_retries + 1):
82
+ try:
83
+ self.producer.send_batch(event_batch)
84
+ return
85
+ except Exception as err: # isolation boundary: never crash the worker (FR-011)
86
+ if attempt < self.max_retries:
87
+ time.sleep(_BACKOFF_BASE * (2**attempt))
88
+ continue
89
+ self.failed += len(event_batch)
90
+ sys.stderr.write(
91
+ f"log-forge: AzureEventHubsSink abandoned a batch of {len(event_batch)} "
92
+ f"event(s) after {self.max_retries + 1} attempts ({err!r})\n"
93
+ )
94
+ return
95
+
96
+
97
+ def _try_add(event_batch: Any, data: Any) -> bool:
98
+ """Add ``data`` to the batch; ``False`` when the batch signals it is full (``ValueError``)."""
99
+ try:
100
+ event_batch.add(data)
101
+ return True
102
+ except ValueError:
103
+ return False
104
+
105
+
106
+ def _event_data_cls() -> Any:
107
+ """Return ``azure.eventhub.EventData`` (indirection seam for tests)."""
108
+ from azure.eventhub import EventData # 'azure-eventhubs' extra (type-ignored in __init__)
109
+
110
+ return EventData