lazers 0.1.0__tar.gz

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.
lazers-0.1.0/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Emmanuel Ilori / Lazers Contributors
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
lazers-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: lazers
3
+ Version: 0.1.0
4
+ Summary: Server-side Python SDK for Lazers Signal Protocol v1
5
+ Author-email: Emmanuel Ilori <iloriemmanuel00@gmail.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/Emmydapson/lazers
8
+ Project-URL: Repository, https://github.com/Emmydapson/lazers
9
+ Project-URL: Issues, https://github.com/Emmydapson/lazers/issues
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Operating System :: OS Independent
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # Lazers Python SDK
22
+
23
+ Server-side Python SDK for Lazers Signal Protocol v1. It uses only the Python standard library and supports Python 3.10+.
24
+
25
+ Repository: [https://github.com/Emmydapson/lazers](https://github.com/Emmydapson/lazers)
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install lazers
31
+ ```
32
+
33
+ ## Quickstart
34
+
35
+ ```python
36
+ from lazers import LazersClient
37
+
38
+ client = LazersClient(
39
+ api_key="...",
40
+ endpoint="https://api.lazers.app",
41
+ project_id="project_1",
42
+ environment="production",
43
+ service_name="my-api",
44
+ )
45
+
46
+ client.capture_error(
47
+ RuntimeError("database timeout"),
48
+ actor={"type": "customer", "id": "cust_2048"},
49
+ )
50
+
51
+ # During shutdown:
52
+ client.flush()
53
+ ```
54
+
55
+ ## Framework guidance
56
+
57
+ FastAPI example: [`examples/fastapi.py`](examples/fastapi.py). Create the client once at app startup, capture in an exception handler, and call `flush()` on shutdown. For Flask, create it with the app and use `@app.errorhandler(Exception)`; for Django, initialize in app configuration and capture in middleware. Capture methods return immediately and never raise telemetry delivery errors.
58
+
59
+ ## Security
60
+
61
+ This is a server SDK. Never embed its secret project API key in browser JavaScript, Flutter/mobile, or any client application. Those platforms need a future client-safe credential/DSN.
lazers-0.1.0/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # Lazers Python SDK
2
+
3
+ Server-side Python SDK for Lazers Signal Protocol v1. It uses only the Python standard library and supports Python 3.10+.
4
+
5
+ Repository: [https://github.com/Emmydapson/lazers](https://github.com/Emmydapson/lazers)
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install lazers
11
+ ```
12
+
13
+ ## Quickstart
14
+
15
+ ```python
16
+ from lazers import LazersClient
17
+
18
+ client = LazersClient(
19
+ api_key="...",
20
+ endpoint="https://api.lazers.app",
21
+ project_id="project_1",
22
+ environment="production",
23
+ service_name="my-api",
24
+ )
25
+
26
+ client.capture_error(
27
+ RuntimeError("database timeout"),
28
+ actor={"type": "customer", "id": "cust_2048"},
29
+ )
30
+
31
+ # During shutdown:
32
+ client.flush()
33
+ ```
34
+
35
+ ## Framework guidance
36
+
37
+ FastAPI example: [`examples/fastapi.py`](examples/fastapi.py). Create the client once at app startup, capture in an exception handler, and call `flush()` on shutdown. For Flask, create it with the app and use `@app.errorhandler(Exception)`; for Django, initialize in app configuration and capture in middleware. Capture methods return immediately and never raise telemetry delivery errors.
38
+
39
+ ## Security
40
+
41
+ This is a server SDK. Never embed its secret project API key in browser JavaScript, Flutter/mobile, or any client application. Those platforms need a future client-safe credential/DSN.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "lazers"
7
+ version = "0.1.0"
8
+ description = "Server-side Python SDK for Lazers Signal Protocol v1"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [
13
+ {name = "Emmanuel Ilori", email = "iloriemmanuel00@gmail.com"}
14
+ ]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Operating System :: OS Independent"
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/Emmydapson/lazers"
26
+ Repository = "https://github.com/Emmydapson/lazers"
27
+ Issues = "https://github.com/Emmydapson/lazers/issues"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
lazers-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from .client import LazersClient, sanitize, serialize_error
2
+
3
+ __all__ = ["LazersClient", "sanitize", "serialize_error"]
@@ -0,0 +1,231 @@
1
+ """Lazers server-side Signal Protocol v1 client."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import threading
7
+ import time
8
+ import traceback
9
+ import uuid
10
+ from collections import deque
11
+ from dataclasses import dataclass
12
+ from typing import Any, Callable, Mapping
13
+ from urllib.error import HTTPError, URLError
14
+ from urllib.request import Request, urlopen
15
+
16
+ PROTOCOL_VERSION = "1"
17
+ SCHEMA_VERSION = "2026-07-09"
18
+ MAX_STRING_LENGTH = 10_000
19
+ MAX_METADATA_KEYS = 100
20
+ MAX_DEPTH = 6
21
+ ACTOR_TYPES = {"user", "customer", "account", "merchant", "tenant", "organization", "other"}
22
+ SENSITIVE_KEYS = ("password", "passwd", "secret", "token", "authorization", "cookie", "api_key", "apikey", "cardnumber", "card", "cvv", "pin", "refreshtoken", "accesstoken", "body", "rawbody", "webhookpayload")
23
+ INVALID_RELEASES = {"git-sha", "sha", "unknown", "replace-me", "placeholder", "undefined", "null"}
24
+ Transport = Callable[[str, Mapping[str, str], bytes, float], int]
25
+
26
+ @dataclass
27
+ class _QueueItem:
28
+ signal: dict[str, Any]
29
+ attempts: int = 0
30
+ next_attempt_at: float = 0.0
31
+
32
+ class LazersClient:
33
+ """Thread-safe, non-blocking server SDK. Capture failures are isolated from the host app."""
34
+ def __init__(self, *, api_key: str, endpoint: str, project_id: str, environment: str,
35
+ service_name: str, release: str | None = None, enabled: bool = True,
36
+ max_queue_size: int = 100, retry_base_delay_ms: int = 250,
37
+ timeout: float = 5.0, max_retries: int = 5,
38
+ transport: Transport | None = None) -> None:
39
+ self.api_key = api_key
40
+ self.endpoint = endpoint.rstrip("/")
41
+ self.project_id = _required(project_id, 200)
42
+ self.environment = _required(environment, 80)
43
+ self.service_name = _required(service_name, 80)
44
+ self.release = _valid_release(release)
45
+ self.enabled = enabled
46
+ self.max_queue_size = max(1, max_queue_size)
47
+ self.retry_base_delay_ms = max(0, retry_base_delay_ms)
48
+ self.timeout = timeout
49
+ self.max_retries = max(0, max_retries)
50
+ self.transport = transport or _default_transport
51
+ self._queue: deque[_QueueItem] = deque()
52
+ self._lock = threading.RLock()
53
+ self._worker: threading.Thread | None = None
54
+
55
+ def capture_signal(self, *, title: str, type: str = "custom.event", event_category: str | None = None,
56
+ payload: Mapping[str, Any] | None = None, severity: str = "info", **context: Any) -> None:
57
+ if not self.enabled:
58
+ return
59
+ try:
60
+ signal = self._normalize_signal(title=title, signal_type=type,
61
+ event_category=event_category or _category_for_type(type), payload=payload or {},
62
+ severity=severity, **context)
63
+ with self._lock:
64
+ self._queue.append(_QueueItem(signal=signal, next_attempt_at=time.monotonic()))
65
+ while len(self._queue) > self.max_queue_size:
66
+ self._queue.popleft()
67
+ self._start_worker()
68
+ except Exception:
69
+ # Telemetry must never affect application flow.
70
+ return
71
+
72
+ def capture_error(self, error: BaseException | Any, **context: Any) -> None:
73
+ serialized = serialize_error(error)
74
+ self.capture_signal(title=serialized["message"], type="error.detected", event_category="error",
75
+ severity=context.pop("severity", "error"),
76
+ payload={**context.pop("payload", {}), "error": serialized}, **context)
77
+
78
+ def capture_message(self, message: str, **context: Any) -> None:
79
+ self.capture_signal(title=message, type="custom.event", event_category="message",
80
+ severity=context.pop("severity", "info"),
81
+ payload={**context.pop("payload", {}), "message": message}, **context)
82
+
83
+ def capture_job_failure(self, job_name: str, error: BaseException | Any, **context: Any) -> None:
84
+ serialized = serialize_error(error)
85
+ self.capture_signal(title=f"Job failed: {job_name}", type="job.failed", event_category="job_failure",
86
+ severity=context.pop("severity", "error"), event_name=context.pop("event_name", "job.failure"),
87
+ resource=context.pop("resource", {"type": "job", "id": job_name, "name": job_name}),
88
+ payload={**context.pop("payload", {}), "error": serialized, "jobName": job_name}, **context)
89
+
90
+ def capture_webhook_failure(self, provider: str, error: BaseException | Any, **context: Any) -> None:
91
+ serialized = serialize_error(error)
92
+ self.capture_signal(title=f"Webhook failed: {provider}", type="integration.failed", event_category="webhook_failure",
93
+ severity=context.pop("severity", "error"), provider=provider,
94
+ source_type=context.pop("source_type", "custom"), event_name=context.pop("event_name", "webhook.failure"),
95
+ payload={**context.pop("payload", {}), "error": serialized, "provider": provider}, **context)
96
+
97
+ def flush(self) -> None:
98
+ """Best-effort synchronous drain, intended for graceful shutdown."""
99
+ if not self.enabled:
100
+ with self._lock: self._queue.clear()
101
+ return
102
+ while True:
103
+ with self._lock:
104
+ if not self._queue: return
105
+ item = self._queue[0]
106
+ if self._send(item):
107
+ with self._lock:
108
+ if self._queue and self._queue[0] is item: self._queue.popleft()
109
+ else:
110
+ # Flush makes one bounded attempt per queued item; leave retryable items queued.
111
+ with self._lock:
112
+ if self._queue and self._queue[0] is item: self._queue.rotate(-1)
113
+ return
114
+
115
+ def close(self) -> None:
116
+ self.flush()
117
+
118
+ def get_queue_size(self) -> int:
119
+ with self._lock: return len(self._queue)
120
+
121
+ def _start_worker(self) -> None:
122
+ with self._lock:
123
+ if self._worker and self._worker.is_alive(): return
124
+ self._worker = threading.Thread(target=self._drain_ready, name="lazers-delivery", daemon=True)
125
+ self._worker.start()
126
+
127
+ def _drain_ready(self) -> None:
128
+ while True:
129
+ with self._lock:
130
+ if not self._queue: return
131
+ item = self._queue[0]
132
+ if item.next_attempt_at > time.monotonic(): return
133
+ if self._send(item):
134
+ with self._lock:
135
+ if self._queue and self._queue[0] is item: self._queue.popleft()
136
+ else:
137
+ return
138
+
139
+ def _send(self, item: _QueueItem) -> bool:
140
+ try:
141
+ status = self.transport(self.endpoint + "/signals/ingest", {
142
+ "Content-Type": "application/json", "x-lazers-api-key": self.api_key,
143
+ }, json.dumps(item.signal, separators=(",", ":")).encode(), self.timeout)
144
+ if 200 <= status < 300 or not _retryable_status(status): return True
145
+ except Exception:
146
+ pass
147
+ item.attempts += 1
148
+ if item.attempts >= self.max_retries: return True
149
+ item.next_attempt_at = time.monotonic() + min((self.retry_base_delay_ms / 1000) * 2 ** max(item.attempts - 1, 0), 30)
150
+ return False
151
+
152
+ def _normalize_signal(self, *, title: str, signal_type: str, event_category: str, payload: Mapping[str, Any], severity: str, **ctx: Any) -> dict[str, Any]:
153
+ release = _valid_release(ctx.get("release", self.release))
154
+ source = ctx.get("source") or {}
155
+ provider = source.get("provider") or ctx.get("provider") or ctx.get("source_provider") or "lazers-agent-sdk"
156
+ source_type = source.get("type") or ctx.get("source_type") or "agent_sdk"
157
+ event_name = source.get("eventName") or ctx.get("event_name") or ctx.get("source_event_name")
158
+ clean_payload = sanitize(payload)
159
+ metadata = sanitize({**(ctx.get("metadata") or {}), **({"release": release} if release else {}), "serviceName": self.service_name})
160
+ error = clean_payload.get("error") if isinstance(clean_payload, dict) else None
161
+ fingerprint = _stable(ctx["fingerprint"], 256) if ctx.get("fingerprint") else _fingerprint(
162
+ event_category, signal_type, provider, event_name, ctx.get("resource"), error, title)
163
+ result: dict[str, Any] = {
164
+ "protocolVersion": PROTOCOL_VERSION, "schemaVersion": SCHEMA_VERSION, "projectId": self.project_id,
165
+ "environment": _required(ctx.get("environment", self.environment), 80), "eventCategory": event_category,
166
+ "type": signal_type, "severity": severity if severity in {"info","notice","warning","error","critical"} else "info",
167
+ "title": _required(title, 240), "occurredAt": ctx.get("occurred_at") or ctx.get("occurredAt") or _iso_now(),
168
+ "fingerprint": fingerprint, "traceId": ctx.get("trace_id") or ctx.get("traceId") or f"exec_{uuid.uuid4()}",
169
+ "source": {"type": source_type, "provider": _required(provider, 80)}, "privacy": ctx.get("privacy") or _privacy(),
170
+ "payload": clean_payload, "metadata": metadata, "status": "received",
171
+ }
172
+ if event_name: result["source"]["eventName"] = _required(event_name, 160)
173
+ for key, aliases in {"correlationId": ("correlation_id", "correlationId"), "actor": ("actor",), "resource": ("resource",), "tags": ("tags",), "labels": ("labels",), "affectedUsers": ("affected_users", "affectedUsers"), "relatedEntities": ("related_entities", "relatedEntities")}.items():
174
+ value = next((ctx[a] for a in aliases if ctx.get(a) is not None), None)
175
+ if value is not None: result[key] = _actor(value) if key == "actor" else sanitize(value)
176
+ return {key: value for key, value in result.items() if value is not None}
177
+
178
+ def serialize_error(error: BaseException | Any, depth: int = 0) -> dict[str, Any]:
179
+ if isinstance(error, BaseException):
180
+ result: dict[str, Any] = {"name": type(error).__name__, "message": str(error)}
181
+ if error.__traceback__: result["stack"] = "".join(traceback.format_exception(type(error), error, error.__traceback__))
182
+ code = getattr(error, "code", None) or getattr(error, "errno", None)
183
+ if isinstance(code, (str, int)): result["code"] = str(code)
184
+ cause = error.__cause__ or (None if error.__suppress_context__ else error.__context__)
185
+ if cause and depth < 3: result["cause"] = serialize_error(cause, depth + 1)
186
+ return sanitize(result)
187
+ return {"name": "NonError", "message": _truncate(str(error), MAX_STRING_LENGTH)}
188
+
189
+ def sanitize(value: Any, depth: int = 0) -> Any:
190
+ if value is None or isinstance(value, (bool, int, float)): return value
191
+ if isinstance(value, str): return _truncate(value, MAX_STRING_LENGTH)
192
+ if isinstance(value, (bytes, bytearray, set)) or callable(value): return "[UNSUPPORTED_OBJECT]"
193
+ if depth >= MAX_DEPTH: return "[TRUNCATED]"
194
+ if isinstance(value, (list, tuple)): return [sanitize(v, depth + 1) for v in value[:MAX_METADATA_KEYS]]
195
+ if isinstance(value, Mapping):
196
+ return {_truncate(str(k), 160): "[REDACTED]" if _sensitive(str(k)) else sanitize(v, depth + 1)
197
+ for k, v in list(value.items())[:MAX_METADATA_KEYS] if v is not ...}
198
+ return "[UNSUPPORTED_OBJECT]"
199
+
200
+ def _fingerprint(category: str, signal_type: str, provider: Any, event_name: Any, resource: Any, error: Any, title: str) -> str:
201
+ resource = resource if isinstance(resource, Mapping) else {}
202
+ error = error if isinstance(error, Mapping) else {}
203
+ parts = [category, signal_type, provider, event_name, resource.get("type"), resource.get("id"), error.get("name"), error.get("message", title)]
204
+ return hashlib.sha256("|".join(_stable(str(v), 512) for v in parts if isinstance(v, str) and v.strip()).encode()).hexdigest()
205
+
206
+ def _actor(value: Any) -> Any:
207
+ if not isinstance(value, Mapping) or not isinstance(value.get("id"), str) or not value["id"].strip(): return None
208
+ result = {"type": value.get("type") if value.get("type") in ACTOR_TYPES else "other", "id": _required(value["id"], 200)}
209
+ if isinstance(value.get("label"), str) and value["label"].strip(): result["label"] = _truncate(value["label"].strip(), 240)
210
+ return result
211
+
212
+ def _sensitive(key: str) -> bool:
213
+ key = "".join(ch for ch in key.lower() if ch.isalnum())
214
+ return any(token.replace("_", "") in key for token in SENSITIVE_KEYS)
215
+ def _valid_release(value: Any) -> str | None:
216
+ return value.strip() if isinstance(value, str) and value.strip() and value.strip().lower() not in INVALID_RELEASES else None
217
+ def _required(value: Any, limit: int) -> str:
218
+ return _truncate(str(value).strip(), limit) or "unknown"
219
+ def _stable(value: str, limit: int) -> str:
220
+ return _truncate(" ".join(value.strip().lower().split()), limit)
221
+ def _truncate(value: str, limit: int) -> str: return value[:limit]
222
+ def _privacy() -> dict[str, Any]: return {"classification":"internal","containsPersonalData":False,"containsSecrets":False,"aiProcessingAllowed":True,"retention":"standard"}
223
+ def _iso_now() -> str:
224
+ from datetime import datetime, timezone
225
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
226
+ def _retryable_status(status: int) -> bool: return status in {408, 429} or 500 <= status <= 599
227
+ def _default_transport(url: str, headers: Mapping[str, str], body: bytes, timeout: float) -> int:
228
+ request = Request(url, data=body, headers=dict(headers), method="POST")
229
+ try:
230
+ with urlopen(request, timeout=timeout) as response: return response.status
231
+ except HTTPError as error: return error.code
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: lazers
3
+ Version: 0.1.0
4
+ Summary: Server-side Python SDK for Lazers Signal Protocol v1
5
+ Author-email: Emmanuel Ilori <iloriemmanuel00@gmail.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/Emmydapson/lazers
8
+ Project-URL: Repository, https://github.com/Emmydapson/lazers
9
+ Project-URL: Issues, https://github.com/Emmydapson/lazers/issues
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Operating System :: OS Independent
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # Lazers Python SDK
22
+
23
+ Server-side Python SDK for Lazers Signal Protocol v1. It uses only the Python standard library and supports Python 3.10+.
24
+
25
+ Repository: [https://github.com/Emmydapson/lazers](https://github.com/Emmydapson/lazers)
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install lazers
31
+ ```
32
+
33
+ ## Quickstart
34
+
35
+ ```python
36
+ from lazers import LazersClient
37
+
38
+ client = LazersClient(
39
+ api_key="...",
40
+ endpoint="https://api.lazers.app",
41
+ project_id="project_1",
42
+ environment="production",
43
+ service_name="my-api",
44
+ )
45
+
46
+ client.capture_error(
47
+ RuntimeError("database timeout"),
48
+ actor={"type": "customer", "id": "cust_2048"},
49
+ )
50
+
51
+ # During shutdown:
52
+ client.flush()
53
+ ```
54
+
55
+ ## Framework guidance
56
+
57
+ FastAPI example: [`examples/fastapi.py`](examples/fastapi.py). Create the client once at app startup, capture in an exception handler, and call `flush()` on shutdown. For Flask, create it with the app and use `@app.errorhandler(Exception)`; for Django, initialize in app configuration and capture in middleware. Capture methods return immediately and never raise telemetry delivery errors.
58
+
59
+ ## Security
60
+
61
+ This is a server SDK. Never embed its secret project API key in browser JavaScript, Flutter/mobile, or any client application. Those platforms need a future client-safe credential/DSN.
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/lazers/__init__.py
5
+ src/lazers/client.py
6
+ src/lazers.egg-info/PKG-INFO
7
+ src/lazers.egg-info/SOURCES.txt
8
+ src/lazers.egg-info/dependency_links.txt
9
+ src/lazers.egg-info/top_level.txt
10
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ lazers
@@ -0,0 +1,55 @@
1
+ import pathlib
2
+ import sys
3
+ import threading
4
+ import time
5
+ import unittest
6
+
7
+ sys.path.insert(0, str(pathlib.Path(__file__).parents[1] / "src"))
8
+ from lazers import LazersClient, sanitize, serialize_error
9
+
10
+ class FakeTransport:
11
+ def __init__(self, statuses=None, fail=False): self.statuses, self.fail, self.requests = list(statuses or [202]), fail, []
12
+ def __call__(self, url, headers, body, timeout):
13
+ if self.fail: raise OSError("offline")
14
+ self.requests.append((url, headers, body))
15
+ return self.statuses.pop(0) if self.statuses else 202
16
+
17
+ class SDKTests(unittest.TestCase):
18
+ def client(self, transport=None, **extra):
19
+ return LazersClient(api_key="key", endpoint="https://api.test/", project_id="project_1", environment=" production ", service_name=" checkout-api ", transport=transport or FakeTransport(), **extra)
20
+ def payload(self, client, transport):
21
+ client.flush(); import json; return json.loads(transport.requests[-1][2])
22
+ def test_protocol_and_helpers(self):
23
+ t=FakeTransport(); c=self.client(t); c.capture_error(ValueError("bad")); body=self.payload(c,t)
24
+ self.assertEqual("1", body["protocolVersion"]); self.assertEqual("error", body["eventCategory"]); self.assertEqual("ValueError", body["payload"]["error"]["name"])
25
+ def test_redaction_and_actor(self):
26
+ self.assertEqual("[REDACTED]", sanitize({"nested":{"accessToken":"x"}})["nested"]["accessToken"])
27
+ t=FakeTransport(); c=self.client(t); c.capture_message("x", actor={"type":"user","id":" u ","email":"no"}); body=self.payload(c,t)
28
+ self.assertEqual({"type":"user","id":"u"},body["actor"])
29
+ def test_trace_and_correlation(self):
30
+ t=FakeTransport(); c=self.client(t); c.capture_message("x"); body=self.payload(c,t)
31
+ self.assertRegex(body["traceId"], r"^exec_"); self.assertNotIn("correlationId",body)
32
+ c.capture_message("x", correlation_id="flow"); body=self.payload(c,t); self.assertEqual("flow",body["correlationId"])
33
+ def test_release_and_fingerprint(self):
34
+ t=FakeTransport(); c=self.client(t, release="unknown"); c.capture_error(RuntimeError("timeout")); a=self.payload(c,t)
35
+ self.assertNotIn("release",a["metadata"]); c.capture_error(RuntimeError("timeout")); b=self.payload(c,t)
36
+ self.assertEqual(a["fingerprint"],b["fingerprint"]); self.assertNotEqual(a["traceId"],b["traceId"])
37
+ def test_queue_oldest_drop_and_disabled(self):
38
+ t=FakeTransport(fail=True); c=self.client(t,max_queue_size=2); c.capture_message("one"); c.capture_message("two"); c.capture_message("three"); time.sleep(.03)
39
+ self.assertEqual(2,c.get_queue_size()); disabled=self.client(t,enabled=False); disabled.capture_message("none"); self.assertEqual(0,disabled.get_queue_size())
40
+ def test_terminal_and_retry_status(self):
41
+ t=FakeTransport([401]); c=self.client(t); c.capture_message("x"); time.sleep(.03); self.assertEqual(0,c.get_queue_size())
42
+ t=FakeTransport([500]); c=self.client(t,retry_base_delay_ms=1); c.capture_message("x"); time.sleep(.03); self.assertEqual(1,c.get_queue_size())
43
+ def test_error_cause_and_fixtures(self):
44
+ try:
45
+ try: raise ValueError("secret token")
46
+ except ValueError as cause: raise RuntimeError("outer") from cause
47
+ except RuntimeError as error: result=serialize_error(error)
48
+ self.assertEqual("RuntimeError", result["name"]); self.assertEqual("ValueError", result["cause"]["name"])
49
+ fixtures=pathlib.Path(__file__).parents[2] / "agent-sdk" / "fixtures"
50
+ self.assertEqual(10,len(list(fixtures.glob("*.json"))))
51
+ def test_thread_safe_capture(self):
52
+ t=FakeTransport(); c=self.client(t); threads=[threading.Thread(target=lambda: c.capture_message("x")) for _ in range(10)]
53
+ [x.start() for x in threads]; [x.join() for x in threads]; c.flush(); self.assertEqual(0,c.get_queue_size())
54
+
55
+ if __name__ == "__main__": unittest.main()