spanforge 1.0.0__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 (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
@@ -0,0 +1,264 @@
1
+ """spanforge.export.siem_splunk — Splunk HTTP Event Collector (HEC) exporter.
2
+
3
+ Forwards spanforge events to a Splunk HTTP Event Collector endpoint.
4
+
5
+ Configuration
6
+ -------------
7
+ ``SPANFORGE_SPLUNK_HEC_URL``
8
+ Required. Full URL of the Splunk HEC endpoint, e.g.
9
+ ``https://splunk.example.com:8088/services/collector/event``.
10
+
11
+ ``SPANFORGE_SPLUNK_HEC_TOKEN``
12
+ Required. Splunk HEC authentication token (``Splunk <token>``).
13
+
14
+ ``SPANFORGE_SPLUNK_INDEX``
15
+ Optional. Splunk index to route events to. Default: ``"main"``.
16
+
17
+ ``SPANFORGE_SPLUNK_SOURCE``
18
+ Optional. Splunk ``source`` field. Default: ``"spanforge"``.
19
+
20
+ ``SPANFORGE_SPLUNK_SOURCETYPE``
21
+ Optional. Splunk ``sourcetype`` field. Default: ``"spanforge:event"``.
22
+
23
+ ``SPANFORGE_SPLUNK_BATCH_SIZE``
24
+ Optional integer. Events per HEC request. Default: ``50``.
25
+
26
+ ``SPANFORGE_SPLUNK_TIMEOUT``
27
+ Optional float (seconds). HTTP request timeout. Default: ``10.0``.
28
+
29
+ Example::
30
+
31
+ import os
32
+ os.environ["SPANFORGE_SPLUNK_HEC_URL"] = "https://splunk:8088/services/collector/event"
33
+ os.environ["SPANFORGE_SPLUNK_HEC_TOKEN"] = "your-token-here"
34
+
35
+ from spanforge.export.siem_splunk import SplunkHECExporter
36
+ exporter = SplunkHECExporter()
37
+ exporter.export(event)
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ import logging
44
+ import os
45
+ import ssl
46
+ import threading
47
+ import time
48
+ import urllib.error
49
+ import urllib.request
50
+ from typing import TYPE_CHECKING, Any
51
+
52
+ from spanforge.export.siem_schema import event_to_siem_record
53
+
54
+ if TYPE_CHECKING:
55
+ from collections.abc import Sequence
56
+
57
+ from spanforge.event import Event
58
+
59
+ __all__ = ["SplunkHECError", "SplunkHECExporter"]
60
+
61
+ _log = logging.getLogger("spanforge.export.siem_splunk")
62
+
63
+ _DEFAULT_BATCH_SIZE = 50
64
+ _DEFAULT_TIMEOUT = 10.0
65
+ _DEFAULT_INDEX = "main"
66
+ _DEFAULT_SOURCE = "spanforge"
67
+ _DEFAULT_SOURCETYPE = "spanforge:event"
68
+
69
+
70
+ class SplunkHECError(RuntimeError):
71
+ """Raised when a Splunk HEC delivery attempt fails permanently."""
72
+
73
+
74
+ class SplunkHECExporter:
75
+ """Export spanforge events to a Splunk HTTP Event Collector endpoint.
76
+
77
+ Args:
78
+ hec_url: Splunk HEC URL. Falls back to ``SPANFORGE_SPLUNK_HEC_URL``.
79
+ token: HEC authentication token. Falls back to
80
+ ``SPANFORGE_SPLUNK_HEC_TOKEN``.
81
+ index: Splunk index. Falls back to ``SPANFORGE_SPLUNK_INDEX``.
82
+ source: Splunk source field. Falls back to ``SPANFORGE_SPLUNK_SOURCE``.
83
+ sourcetype: Splunk sourcetype field. Falls back to
84
+ ``SPANFORGE_SPLUNK_SOURCETYPE``.
85
+ batch_size: Events per HTTP request. Falls back to
86
+ ``SPANFORGE_SPLUNK_BATCH_SIZE`` (default 50).
87
+ timeout: HTTP request timeout in seconds. Falls back to
88
+ ``SPANFORGE_SPLUNK_TIMEOUT`` (default 10.0).
89
+ verify_ssl: Whether to verify the server TLS certificate. Default
90
+ ``True``; set to ``False`` only in controlled lab
91
+ environments.
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ *,
97
+ hec_url: str = "",
98
+ token: str = "",
99
+ index: str = "",
100
+ source: str = "",
101
+ sourcetype: str = "",
102
+ batch_size: int = 0,
103
+ timeout: float = 0.0,
104
+ verify_ssl: bool = True,
105
+ ) -> None:
106
+ self._hec_url: str = hec_url or os.environ.get("SPANFORGE_SPLUNK_HEC_URL", "")
107
+ self._token: str = token or os.environ.get("SPANFORGE_SPLUNK_HEC_TOKEN", "")
108
+ self._index: str = index or os.environ.get("SPANFORGE_SPLUNK_INDEX", _DEFAULT_INDEX)
109
+ self._source: str = source or os.environ.get("SPANFORGE_SPLUNK_SOURCE", _DEFAULT_SOURCE)
110
+ self._sourcetype: str = sourcetype or os.environ.get(
111
+ "SPANFORGE_SPLUNK_SOURCETYPE", _DEFAULT_SOURCETYPE
112
+ )
113
+ self._batch_size: int = batch_size or int(
114
+ os.environ.get("SPANFORGE_SPLUNK_BATCH_SIZE", _DEFAULT_BATCH_SIZE)
115
+ )
116
+ self._timeout: float = timeout or float(
117
+ os.environ.get("SPANFORGE_SPLUNK_TIMEOUT", _DEFAULT_TIMEOUT)
118
+ )
119
+ self._verify_ssl: bool = verify_ssl
120
+ self._lock: threading.Lock = threading.Lock()
121
+ self._pending: list[dict[str, Any]] = []
122
+ self._sent_count: int = 0
123
+ self._error_count: int = 0
124
+
125
+ if not self._hec_url:
126
+ raise ValueError(
127
+ "Splunk HEC URL must be provided via hec_url argument or "
128
+ "SPANFORGE_SPLUNK_HEC_URL environment variable"
129
+ )
130
+ # Enforce HTTP/HTTPS-only — prevents file:// or custom-scheme injection (B310)
131
+ parsed_scheme = self._hec_url.split("://", 1)[0].lower() if "://" in self._hec_url else ""
132
+ if parsed_scheme not in ("http", "https"):
133
+ raise ValueError(
134
+ f"Splunk HEC URL must use http:// or https:// scheme, got: {self._hec_url!r}"
135
+ )
136
+ if not self._token:
137
+ raise ValueError(
138
+ "Splunk HEC token must be provided via token argument or "
139
+ "SPANFORGE_SPLUNK_HEC_TOKEN environment variable"
140
+ )
141
+ # Reject plaintext HTTP in non-test environments
142
+ if (
143
+ self._hec_url.startswith("http://")
144
+ and not self._hec_url.startswith("http://localhost")
145
+ and not self._hec_url.startswith("http://127.")
146
+ ):
147
+ _log.warning(
148
+ "Splunk HEC URL uses plaintext HTTP — use HTTPS in production: %s",
149
+ self._hec_url,
150
+ )
151
+
152
+ # ------------------------------------------------------------------
153
+ # Public API
154
+ # ------------------------------------------------------------------
155
+
156
+ def export(self, event: Event) -> None:
157
+ """Buffer *event* and flush when batch_size is reached."""
158
+ payload = self._build_hec_payload(event)
159
+ with self._lock:
160
+ self._pending.append(payload)
161
+ if len(self._pending) >= self._batch_size:
162
+ self._flush_locked()
163
+
164
+ def export_batch(self, events: Sequence[Event]) -> int:
165
+ """Export a batch of events. Returns the number of events sent."""
166
+ for event in events:
167
+ self.export(event)
168
+ self.flush()
169
+ return len(events)
170
+
171
+ def flush(self) -> None:
172
+ """Force-flush any buffered events to Splunk HEC."""
173
+ with self._lock:
174
+ self._flush_locked()
175
+
176
+ def close(self) -> None:
177
+ """Flush and release resources."""
178
+ self.flush()
179
+
180
+ @property
181
+ def sent_count(self) -> int:
182
+ """Total number of events successfully sent to Splunk."""
183
+ return self._sent_count
184
+
185
+ @property
186
+ def error_count(self) -> int:
187
+ """Total number of delivery failures."""
188
+ return self._error_count
189
+
190
+ # ------------------------------------------------------------------
191
+ # Context manager support
192
+ # ------------------------------------------------------------------
193
+
194
+ def __enter__(self) -> SplunkHECExporter:
195
+ return self
196
+
197
+ def __exit__(self, *_: Any) -> None:
198
+ self.close()
199
+
200
+ # ------------------------------------------------------------------
201
+ # Internal helpers
202
+ # ------------------------------------------------------------------
203
+
204
+ def _build_hec_payload(self, event: Event) -> dict[str, Any]:
205
+ """Convert a spanforge Event to a Splunk HEC event dict."""
206
+ return {
207
+ "time": event.timestamp if hasattr(event, "timestamp") else time.time(),
208
+ "index": self._index,
209
+ "source": self._source,
210
+ "sourcetype": self._sourcetype,
211
+ "event": event_to_siem_record(event),
212
+ }
213
+
214
+ def _flush_locked(self) -> None:
215
+ """Send all pending payloads. Must be called with ``_lock`` held."""
216
+ if not self._pending:
217
+ return
218
+ batch = self._pending[:]
219
+ self._pending.clear()
220
+ try:
221
+ self._send(batch)
222
+ self._sent_count += len(batch)
223
+ except Exception as exc:
224
+ self._error_count += len(batch)
225
+ _log.error("SplunkHECExporter: failed to send %d events — %s", len(batch), exc)
226
+
227
+ def _send(self, payloads: list[dict[str, Any]]) -> None:
228
+ """POST *payloads* to the Splunk HEC endpoint.
229
+
230
+ Multiple events are encoded as newline-delimited JSON (Splunk's
231
+ raw HEC format).
232
+
233
+ Raises:
234
+ SplunkHECError: On a permanent 4xx / 5xx response.
235
+ """
236
+ body = "\n".join(json.dumps(p) for p in payloads).encode()
237
+ headers = {
238
+ "Authorization": f"Splunk {self._token}",
239
+ "Content-Type": "application/json",
240
+ }
241
+ req = urllib.request.Request(self._hec_url, data=body, headers=headers, method="POST")
242
+ ctx: ssl.SSLContext | None = None
243
+ if not self._verify_ssl:
244
+ ctx = ssl.create_default_context()
245
+ ctx.check_hostname = False
246
+ ctx.verify_mode = ssl.CERT_NONE
247
+
248
+ try:
249
+ with urllib.request.urlopen(req, timeout=self._timeout, context=ctx) as resp: # nosec B310 — scheme validated to http/https in __init__
250
+ if resp.status >= 400:
251
+ raise SplunkHECError(
252
+ f"Splunk HEC returned HTTP {resp.status} for {len(payloads)} events"
253
+ )
254
+ except urllib.error.HTTPError as exc:
255
+ raise SplunkHECError(f"Splunk HEC HTTP error {exc.code}: {exc.reason}") from exc
256
+ except urllib.error.URLError as exc:
257
+ raise SplunkHECError(f"Splunk HEC connection error: {exc.reason}") from exc
258
+
259
+ def __repr__(self) -> str:
260
+ return (
261
+ f"SplunkHECExporter(hec_url={self._hec_url!r}, "
262
+ f"index={self._index!r}, "
263
+ f"sent={self._sent_count}, errors={self._error_count})"
264
+ )
@@ -0,0 +1,212 @@
1
+ """spanforge.export.siem_syslog - Syslog / CEF exporter.
2
+
3
+ Forwards spanforge events to a remote syslog receiver (RFC 5424) optionally
4
+ encoded as ArcSight Common Event Format (CEF).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import re
13
+ import socket
14
+ import threading
15
+ from datetime import datetime, timezone
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from spanforge.export.siem_schema import event_to_siem_record, severity_from_event
19
+
20
+ if TYPE_CHECKING:
21
+ from spanforge.event import Event
22
+
23
+ __all__ = ["SyslogExporter", "SyslogExporterError"]
24
+
25
+ _log = logging.getLogger("spanforge.export.siem_syslog")
26
+
27
+ _DEFAULT_PORT = 514
28
+ _DEFAULT_TRANSPORT = "udp"
29
+ _DEFAULT_FORMAT = "rfc5424"
30
+ _DEFAULT_APP_NAME = "spanforge"
31
+ _DEFAULT_FACILITY = 16 # local0
32
+ _SEVERITY_MAP: dict[str, int] = {
33
+ "alert": 1,
34
+ "error": 3,
35
+ "warn": 4,
36
+ "warning": 4,
37
+ "info": 6,
38
+ "debug": 7,
39
+ "trace": 7,
40
+ }
41
+
42
+ _CEF_VENDOR = "SpanForge"
43
+ _CEF_PRODUCT = "SpanForge"
44
+ _CEF_VERSION = "1.0"
45
+ _CEF_ESCAPE_RE = re.compile(r"([\\|=])")
46
+
47
+
48
+ class SyslogExporterError(RuntimeError):
49
+ """Raised when a syslog delivery attempt fails permanently."""
50
+
51
+
52
+ class SyslogExporter:
53
+ """Export spanforge events to a remote syslog receiver."""
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ host: str = "",
59
+ port: int = 0,
60
+ transport: str = "",
61
+ format: str = "",
62
+ app_name: str = "",
63
+ facility: int = -1,
64
+ ) -> None:
65
+ self._host: str = host or os.environ.get("SPANFORGE_SYSLOG_HOST", "")
66
+ self._port: int = port or int(os.environ.get("SPANFORGE_SYSLOG_PORT", _DEFAULT_PORT))
67
+ self._transport: str = (
68
+ transport or os.environ.get("SPANFORGE_SYSLOG_TRANSPORT", _DEFAULT_TRANSPORT)
69
+ ).lower()
70
+ self._format: str = (
71
+ format or os.environ.get("SPANFORGE_SYSLOG_FORMAT", _DEFAULT_FORMAT)
72
+ ).lower()
73
+ self._app_name: str = app_name or os.environ.get(
74
+ "SPANFORGE_SYSLOG_APP_NAME", _DEFAULT_APP_NAME
75
+ )
76
+ self._facility: int = (
77
+ facility
78
+ if facility >= 0
79
+ else int(os.environ.get("SPANFORGE_SYSLOG_FACILITY", _DEFAULT_FACILITY))
80
+ )
81
+ self._lock: threading.Lock = threading.Lock()
82
+ self._sent_count: int = 0
83
+ self._error_count: int = 0
84
+
85
+ if not self._host:
86
+ raise ValueError(
87
+ "Syslog host must be provided via host argument or "
88
+ "SPANFORGE_SYSLOG_HOST environment variable"
89
+ )
90
+ if self._transport not in ("udp", "tcp"):
91
+ raise ValueError(f"transport must be 'udp' or 'tcp', got {self._transport!r}")
92
+ if self._format not in ("rfc5424", "cef"):
93
+ raise ValueError(f"format must be 'rfc5424' or 'cef', got {self._format!r}")
94
+ if not (0 <= self._facility <= 23):
95
+ raise ValueError(f"facility must be in range 0-23, got {self._facility}")
96
+
97
+ def export(self, event: Event) -> None:
98
+ """Encode *event* and send it to the syslog receiver."""
99
+ message = self._format_cef(event) if self._format == "cef" else self._format_rfc5424(event)
100
+ try:
101
+ self._send(message)
102
+ with self._lock:
103
+ self._sent_count += 1
104
+ except Exception as exc:
105
+ with self._lock:
106
+ self._error_count += 1
107
+ _log.error("SyslogExporter: failed to send event - %s", exc)
108
+
109
+ def close(self) -> None:
110
+ """No persistent connection; this is a no-op for UDP mode."""
111
+
112
+ @property
113
+ def sent_count(self) -> int:
114
+ """Total events successfully delivered."""
115
+ return self._sent_count
116
+
117
+ @property
118
+ def error_count(self) -> int:
119
+ """Total delivery failures."""
120
+ return self._error_count
121
+
122
+ def __enter__(self) -> SyslogExporter:
123
+ return self
124
+
125
+ def __exit__(self, *_: Any) -> None:
126
+ self.close()
127
+
128
+ def _severity_from_event(self, event: Event) -> int:
129
+ """Map event_type prefix to a syslog severity (0-7)."""
130
+ return severity_from_event(event)
131
+
132
+ def _priority(self, severity: int) -> int:
133
+ """Compute syslog PRI value from facility and severity."""
134
+ return self._facility * 8 + severity
135
+
136
+ def _format_rfc5424(self, event: Event) -> str:
137
+ """Format event as an RFC 5424 syslog message."""
138
+ severity = self._severity_from_event(event)
139
+ pri = self._priority(severity)
140
+ timestamp = (
141
+ datetime.now(tz=timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
142
+ )
143
+ hostname = socket.gethostname()
144
+ proc_id = "-"
145
+ msg_id = event.event_type.replace(" ", "_")
146
+ structured_data = "-"
147
+ record_json = json.dumps(event_to_siem_record(event), sort_keys=True)
148
+ msg = f"spanforge event_id={event.event_id} payload={record_json}"
149
+ return (
150
+ f"<{pri}>1 {timestamp} {hostname} {self._app_name} "
151
+ f"{proc_id} {msg_id} {structured_data} {msg}"
152
+ )
153
+
154
+ def _format_cef(self, event: Event) -> str:
155
+ """Format event as a CEF (ArcSight Common Event Format) message."""
156
+ severity = self._severity_from_event(event)
157
+ pri = self._priority(severity)
158
+ timestamp = (
159
+ datetime.now(tz=timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
160
+ )
161
+ hostname = socket.gethostname()
162
+ event_type_escaped = _CEF_ESCAPE_RE.sub(r"\\\1", event.event_type)
163
+ cef_header = (
164
+ f"CEF:0|{_CEF_VENDOR}|{_CEF_PRODUCT}|{_CEF_VERSION}|"
165
+ f"{event_type_escaped}|{event_type_escaped}|{severity}|"
166
+ )
167
+ extensions: dict[str, str] = {
168
+ "rt": timestamp,
169
+ "deviceExternalId": event.event_id,
170
+ "app": self._app_name,
171
+ "event_type": event.event_type,
172
+ }
173
+ siem_record = event_to_siem_record(event)
174
+ for key, value in siem_record.items():
175
+ if key == "payload":
176
+ continue
177
+ safe_key = re.sub(r"[^A-Za-z0-9_]", "_", str(key))
178
+ safe_value = (
179
+ json.dumps(value, sort_keys=True) if isinstance(value, (dict, list)) else str(value)
180
+ )
181
+ extensions[safe_key] = _CEF_ESCAPE_RE.sub(r"\\\1", safe_value)
182
+ payload = getattr(event, "payload", {}) or {}
183
+ if isinstance(payload, dict):
184
+ for key, value in payload.items():
185
+ safe_key = re.sub(r"[^A-Za-z0-9_]", "_", str(key))
186
+ safe_value = _CEF_ESCAPE_RE.sub(r"\\\1", str(value))
187
+ extensions[safe_key] = safe_value
188
+ ext_str = " ".join(f"{key}={value}" for key, value in extensions.items())
189
+ syslog_prefix = f"<{pri}>1 {timestamp} {hostname} {self._app_name} - - - "
190
+ return syslog_prefix + cef_header + ext_str
191
+
192
+ def _send(self, message: str) -> None:
193
+ """Deliver *message* via UDP or TCP syslog."""
194
+ data = (message + "\n").encode("utf-8", errors="replace")
195
+ try:
196
+ if self._transport == "udp":
197
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
198
+ sock.sendto(data, (self._host, self._port))
199
+ else:
200
+ with socket.create_connection((self._host, self._port), timeout=5.0) as sock:
201
+ sock.sendall(data)
202
+ except OSError as exc:
203
+ raise SyslogExporterError(
204
+ f"Syslog delivery failed to {self._host}:{self._port} ({self._transport}): {exc}"
205
+ ) from exc
206
+
207
+ def __repr__(self) -> str:
208
+ return (
209
+ f"SyslogExporter(host={self._host!r}, port={self._port}, "
210
+ f"transport={self._transport!r}, format={self._format!r}, "
211
+ f"sent={self._sent_count}, errors={self._error_count})"
212
+ )