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 +17 -0
- lazers-0.1.0/PKG-INFO +61 -0
- lazers-0.1.0/README.md +41 -0
- lazers-0.1.0/pyproject.toml +30 -0
- lazers-0.1.0/setup.cfg +4 -0
- lazers-0.1.0/src/lazers/__init__.py +3 -0
- lazers-0.1.0/src/lazers/client.py +231 -0
- lazers-0.1.0/src/lazers.egg-info/PKG-INFO +61 -0
- lazers-0.1.0/src/lazers.egg-info/SOURCES.txt +10 -0
- lazers-0.1.0/src/lazers.egg-info/dependency_links.txt +1 -0
- lazers-0.1.0/src/lazers.egg-info/top_level.txt +1 -0
- lazers-0.1.0/tests/test_client.py +55 -0
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,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 @@
|
|
|
1
|
+
|
|
@@ -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()
|