devlite 0.1.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.
devlite/__init__.py ADDED
@@ -0,0 +1,147 @@
1
+ """DevLite Python SDK — AI-powered observability with a 2-line integration.
2
+
3
+ import devlite
4
+
5
+ devlite.init({api_key: "dl_live_xxxxx"})
6
+
7
+ Automatic request tracking, error grouping, source context, user impact
8
+ tracking, and sensitive-data scrubbing — mirroring the @devlite/nodejs npm
9
+ SDK, with zero runtime dependencies.
10
+
11
+ Public API (all Pythonic snake_case):
12
+ init, capture_error, capture_message, report_metric, capture_log,
13
+ start_span, report_span, report_deployment, add_breadcrumb, set_tag,
14
+ set_user, run_with_context, start_host_metrics, instrument_flask,
15
+ flush, close
16
+ """
17
+
18
+ from ._client import Client, HostMetricsHandle, Span
19
+ from ._context import run_with_context
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ _client = None
24
+
25
+
26
+ def init(options=None):
27
+ """Initialize DevLite. The only required call.
28
+
29
+ Args:
30
+ options: dict with keys like api_key, environment, service_name,
31
+ release, sample_rate, gzip, debug, endpoint, on_error, ...
32
+ `api_key` is required (or set DEVLITE_API_KEY env var).
33
+ """
34
+ global _client
35
+ if _client is not None:
36
+ import warnings
37
+
38
+ warnings.warn("[DevLite] init() called more than once — ignoring subsequent call.")
39
+ return
40
+ _client = Client(options or {})
41
+
42
+
43
+ def _require_client():
44
+ if _client is None:
45
+ raise RuntimeError("[DevLite] SDK not initialized. Call devlite.init(api_key=...) first.")
46
+ return _client
47
+
48
+
49
+ def capture_error(error, extra=None):
50
+ """Capture a handled error with optional extra context."""
51
+ _require_client().capture_error(error, extra=extra)
52
+
53
+
54
+ def capture_message(message, level="info", extra=None):
55
+ """Capture a non-error event, e.g. a business-logic message."""
56
+ _require_client().capture_message(message, level=level, extra=extra)
57
+
58
+
59
+ def report_metric(name, value, unit=None, tags=None):
60
+ """Report a metric data point (feeds forecasting/anomaly detection)."""
61
+ _require_client().report_metric(name, value, unit=unit, tags=tags)
62
+
63
+
64
+ def capture_log(message, level="info", fields=None):
65
+ """Capture a structured log line with arbitrary fields."""
66
+ _require_client().capture_log(message, level=level, fields=fields)
67
+
68
+
69
+ def start_span(name, options=None):
70
+ """Start a trace span; returns an object with id/trace_id and end(status)."""
71
+ return _require_client().start_span(name, options=options)
72
+
73
+
74
+ def report_span(name, trace_id=None, parent_id=None, tags=None, start_time=None, end_time=None, duration_ms=None, status="ok"):
75
+ """Report a finished span if you track timing yourself."""
76
+ _require_client().report_span(
77
+ name,
78
+ trace_id=trace_id,
79
+ parent_id=parent_id,
80
+ tags=tags,
81
+ start_time=start_time,
82
+ end_time=end_time,
83
+ duration_ms=duration_ms,
84
+ status=status,
85
+ )
86
+
87
+
88
+ def report_deployment(version=None, commit_sha=None, notes=None):
89
+ """Report a deployment — powers performance before/after deployment views."""
90
+ _require_client().report_deployment(version=version, commit_sha=commit_sha, notes=notes)
91
+
92
+
93
+ def add_breadcrumb(entry):
94
+ """Add a manual breadcrumb (e.g. 'user started checkout') for richer AI context."""
95
+ _require_client().add_breadcrumb(entry)
96
+
97
+
98
+ def set_tag(key, value):
99
+ """Tag all subsequent events, e.g. set_tag('region', 'lagos')."""
100
+ _require_client().set_tag(key, value)
101
+
102
+
103
+ def set_user(user):
104
+ """Identify the user for the CURRENT request (scoped to the request)."""
105
+ _require_client().set_user(user)
106
+
107
+
108
+ def start_host_metrics(interval_ms=15000):
109
+ """Start sampling host CPU/memory (plus network/disk on Linux) as metrics."""
110
+ return _require_client().start_host_metrics(interval_ms=interval_ms)
111
+
112
+
113
+ def instrument_flask(app):
114
+ """Wire automatic request/error/user tracking into a Flask app."""
115
+ return _require_client().instrument_flask(app)
116
+
117
+
118
+ def flush():
119
+ """Force-send whatever is currently queued (useful in serverless)."""
120
+ _require_client().flush()
121
+
122
+
123
+ def close():
124
+ """Cleanly stop the SDK — flushes remaining events."""
125
+ if _client is not None:
126
+ _client.close()
127
+
128
+
129
+ __all__ = [
130
+ "__version__",
131
+ "init",
132
+ "capture_error",
133
+ "capture_message",
134
+ "report_metric",
135
+ "capture_log",
136
+ "start_span",
137
+ "report_span",
138
+ "report_deployment",
139
+ "add_breadcrumb",
140
+ "set_tag",
141
+ "set_user",
142
+ "run_with_context",
143
+ "start_host_metrics",
144
+ "instrument_flask",
145
+ "flush",
146
+ "close",
147
+ ]
devlite/_client.py ADDED
@@ -0,0 +1,184 @@
1
+ """DevLite client — the singleton that owns config, transport, and context."""
2
+
3
+ import atexit
4
+ import threading
5
+ import uuid
6
+
7
+ from ._config import build_config
8
+ from ._context import Context, get_user, now_ms, run_with_context
9
+ from ._errors import build_error_event
10
+ from ._metrics import collect_host_metrics
11
+ from ._scrub import scrub_deep
12
+ from ._transport import Transport
13
+
14
+
15
+ def _new_hex(nbytes):
16
+ return uuid.uuid4().hex[: nbytes * 2]
17
+
18
+
19
+ class Span:
20
+ """A trace span you can end() once the work completes."""
21
+
22
+ def __init__(self, client, name, options=None):
23
+ options = options or {}
24
+ self.client = client
25
+ self.type = "span"
26
+ self.id = options.get("id") or _new_hex(6)
27
+ self.name = name
28
+ self.trace_id = options.get("trace_id") or _new_hex(8)
29
+ self.parent_id = options.get("parent_id")
30
+ self.tags = options.get("tags") or {}
31
+ self.start_time = now_ms()
32
+ self.end_time = None
33
+ self.duration_ms = None
34
+ self.status = None
35
+
36
+ def end(self, status="ok"):
37
+ self.end_time = now_ms()
38
+ self.duration_ms = self.end_time - self.start_time
39
+ self.status = status
40
+ event = {
41
+ "type": "span",
42
+ "id": self.id,
43
+ "name": self.name,
44
+ "traceId": self.trace_id,
45
+ "parentId": self.parent_id,
46
+ "tags": self.tags,
47
+ "startTime": self.start_time,
48
+ "endTime": self.end_time,
49
+ "durationMs": self.duration_ms,
50
+ "status": status,
51
+ }
52
+ self.client._enqueue_scubbed(event)
53
+
54
+
55
+ class HostMetricsHandle:
56
+ def __init__(self, client, interval_ms):
57
+ self.client = client
58
+ self.interval_ms = max(1000, interval_ms)
59
+ self._stop = threading.Event()
60
+ self._thread = threading.Thread(target=self._run, daemon=True, name="devlite-host-metrics")
61
+ self._thread.start()
62
+
63
+ def _run(self):
64
+ self._sample()
65
+ while not self._stop.wait(self.interval_ms / 1000.0):
66
+ self._sample()
67
+
68
+ def _sample(self):
69
+ try:
70
+ for metric in collect_host_metrics():
71
+ self.client._transport.enqueue(metric)
72
+ except Exception:
73
+ pass
74
+
75
+ def stop(self):
76
+ self._stop.set()
77
+
78
+
79
+ class _NoopHandle:
80
+ def stop(self):
81
+ pass
82
+
83
+
84
+ class Client:
85
+ def __init__(self, options):
86
+ self.config = build_config(options)
87
+ self.context = Context()
88
+ self.transport = Transport(self.config)
89
+ self._host_metrics = None
90
+ self._closed = False
91
+ atexit.register(self.close)
92
+
93
+ # --- public API (also exposed at package top level) -------------------
94
+
95
+ def capture_error(self, error, extra=None):
96
+ event = build_error_event(
97
+ error,
98
+ self.config,
99
+ breadcrumbs=self.context.snapshot().get("breadcrumbs", []),
100
+ user=get_user(),
101
+ extra=extra or {},
102
+ )
103
+ self.transport.enqueue(event)
104
+
105
+ def capture_message(self, message, level="info", extra=None):
106
+ raw = {"type": "message", "level": level, "message": message, "extra": extra or {}, "timestamp": now_ms()}
107
+ self.transport.enqueue(scrub_deep(raw) if self._should_scrub() else raw)
108
+
109
+ def report_metric(self, name, value, unit=None, tags=None):
110
+ if not isinstance(name, str) or not name:
111
+ raise ValueError("[DevLite] report_metric() requires a string `name`.")
112
+ if not isinstance(value, (int, float)):
113
+ raise ValueError("[DevLite] report_metric() requires a numeric `value`.")
114
+ raw = {"type": "metric", "name": name, "value": value, "unit": unit or None, "tags": tags or {}, "timestamp": now_ms()}
115
+ self.transport.enqueue(scrub_deep(raw) if self._should_scrub() else raw)
116
+
117
+ def capture_log(self, message, level="info", fields=None):
118
+ raw = {"type": "log", "level": level, "message": message, "fields": fields or {}, "timestamp": now_ms()}
119
+ self.transport.enqueue(scrub_deep(raw) if self._should_scrub() else raw)
120
+
121
+ def start_span(self, name, options=None):
122
+ return Span(self, name, options)
123
+
124
+ def report_span(self, name, trace_id=None, parent_id=None, tags=None, start_time=None, end_time=None, duration_ms=None, status="ok"):
125
+ raw = {
126
+ "type": "span",
127
+ "id": _new_hex(6),
128
+ "name": name,
129
+ "traceId": trace_id or _new_hex(8),
130
+ "parentId": parent_id,
131
+ "tags": tags or {},
132
+ "startTime": start_time,
133
+ "endTime": end_time,
134
+ "durationMs": duration_ms,
135
+ "status": status,
136
+ "timestamp": now_ms(),
137
+ }
138
+ self.transport.enqueue(scrub_deep(raw) if self._should_scrub() else raw)
139
+
140
+ def report_deployment(self, version=None, commit_sha=None, notes=None):
141
+ self.transport.enqueue(
142
+ {"type": "deployment", "version": version, "commitSha": commit_sha, "notes": notes, "timestamp": now_ms()}
143
+ )
144
+
145
+ def add_breadcrumb(self, entry):
146
+ self.context.add_breadcrumb(entry or {})
147
+
148
+ def set_tag(self, key, value):
149
+ self.context.set_tag(key, value)
150
+
151
+ def set_user(self, user):
152
+ from ._context import set_user as _set_user
153
+
154
+ _set_user(user)
155
+
156
+ def start_host_metrics(self, interval_ms=15000):
157
+ if self._host_metrics:
158
+ return _NoopHandle() # no-op if already running
159
+ self._host_metrics = HostMetricsHandle(self, interval_ms)
160
+ return self._host_metrics
161
+
162
+ def instrument_flask(self, app):
163
+ from ._flask import instrument_flask
164
+
165
+ return instrument_flask(app, self.transport, self.context, self.config)
166
+
167
+ def flush(self):
168
+ self.transport.flush()
169
+
170
+ def close(self):
171
+ if self._closed:
172
+ return
173
+ self._closed = True
174
+ if self._host_metrics:
175
+ self._host_metrics.stop()
176
+ self.transport.close()
177
+
178
+ # --- internals --------------------------------------------------------
179
+
180
+ def _should_scrub(self):
181
+ return self.config.get("scrub_sensitive_data", True) is not False
182
+
183
+ def _enqueue_scubbed(self, event):
184
+ self.transport.enqueue(scrub_deep(event) if self._should_scrub() else event)
devlite/_config.py ADDED
@@ -0,0 +1,62 @@
1
+ """Configuration for the DevLite Python SDK.
2
+
3
+ Mirrors devlite-sdk's config.js so both SDKs behave identically. Defaults
4
+ are deliberately safe: scrubbing on, request bodies off, bounded queue.
5
+ """
6
+
7
+ import os
8
+
9
+ DEFAULT_ENDPOINT = "https://devlite.andasy.dev/v1/events"
10
+ DEFAULT_ENVIRONMENT = os.environ.get("DEVLITE_ENVIRONMENT", "development")
11
+ DEFAULT_SERVICE_NAME = os.environ.get(
12
+ "DEVLITE_SERVICE_NAME",
13
+ os.path.basename(os.getcwd()) or "python-service",
14
+ )
15
+ DEFAULT_RELEASE = os.environ.get("DEVLITE_RELEASE", None)
16
+
17
+ DEFAULTS = {
18
+ "endpoint": DEFAULT_ENDPOINT,
19
+ "environment": DEFAULT_ENVIRONMENT,
20
+ "service_name": DEFAULT_SERVICE_NAME,
21
+ "release": DEFAULT_RELEASE,
22
+ "flush_interval_ms": 5000,
23
+ "max_batch_size": 100,
24
+ "max_queue_size": 5000, # hard ceiling to protect memory if network is down
25
+ "sample_rate": 1.0, # 1.0 = capture 100% of requests
26
+ "capture_body": False, # whether to capture (redacted) request headers
27
+ "redact_headers": ["authorization", "cookie", "x-api-key"],
28
+ "debug": False,
29
+ "max_retries": 3,
30
+ "retry_base_delay_ms": 500,
31
+ "gzip": True, # compress request bodies with Content-Encoding: gzip
32
+ "on_error": None, # optional callback(err) for SDK-internal failures
33
+ "capture_source_context": True, # attach surrounding source lines to errors
34
+ "scrub_sensitive_data": True, # auto-redact emails, tokens, credit cards, etc.
35
+ "capture_console_breadcrumbs": True, # log warnings/errors become breadcrumbs
36
+ "request_timeout_ms": 10000,
37
+ }
38
+
39
+
40
+ def build_config(options):
41
+ """Merge user options over the defaults; validates the essentials.
42
+
43
+ Raises ValueError with a clear message on invalid config, mirroring
44
+ buildConfig() in the Node SDK.
45
+ """
46
+ if not options:
47
+ options = {}
48
+ api_key = options.get("api_key") or os.environ.get("DEVLITE_API_KEY")
49
+ if not api_key:
50
+ raise ValueError(
51
+ "[DevLite] init() requires an `api_key` (or DEVLITE_API_KEY env var)."
52
+ )
53
+
54
+ config = dict(DEFAULTS)
55
+ config.update({k: v for k, v in options.items() if v is not None})
56
+ config["api_key"] = api_key
57
+
58
+ sample_rate = config["sample_rate"]
59
+ if not isinstance(sample_rate, (int, float)) or sample_rate < 0 or sample_rate > 1:
60
+ raise ValueError("[DevLite] `sample_rate` must be a number between 0 and 1.")
61
+
62
+ return config
devlite/_context.py ADDED
@@ -0,0 +1,78 @@
1
+ """Request-scoped context: breadcrumbs, tags, and the current user.
2
+
3
+ Python equivalent of context.js + userContext.js from the Node SDK. Uses
4
+ contextvars (the modern replacement for thread-locals) so user context set
5
+ inside one request handler never leaks across concurrent requests.
6
+ """
7
+
8
+ import contextvars
9
+ import time
10
+
11
+ MAX_BREADCRUMBS = 20
12
+
13
+ _request_store = contextvars.ContextVar("devlite_request_store", default=None)
14
+
15
+
16
+ def _store():
17
+ store = _request_store.get()
18
+ if store is None:
19
+ store = {}
20
+ _request_store.set(store)
21
+ return store
22
+
23
+
24
+ class Context:
25
+ """Keeps a small rolling log of recent events (breadcrumbs) and global tags."""
26
+
27
+ def __init__(self):
28
+ self.breadcrumbs = []
29
+ self.tags = {}
30
+
31
+ def add_breadcrumb(self, entry):
32
+ self.breadcrumbs.append({"timestamp": now_ms(), **entry})
33
+ if len(self.breadcrumbs) > MAX_BREADCRUMBS:
34
+ self.breadcrumbs.pop(0)
35
+
36
+ def set_tag(self, key, value):
37
+ self.tags[key] = value
38
+
39
+ def snapshot(self):
40
+ return {"breadcrumbs": list(self.breadcrumbs), "tags": dict(self.tags)}
41
+
42
+
43
+ def run_with_context(fn, seed=None):
44
+ """Run fn inside a fresh request context, optionally seeded (e.g. sampled=False)."""
45
+ previous = _request_store.get()
46
+ _request_store.set(dict(seed) if seed else {})
47
+ try:
48
+ return fn()
49
+ finally:
50
+ _request_store.set(previous)
51
+
52
+
53
+ def set_user(user):
54
+ """Scope a user to the current request context. Mirrors devlite.setUser()."""
55
+ store = _store()
56
+ if isinstance(user, dict):
57
+ store["user"] = user
58
+ else:
59
+ store["user"] = {"id": str(user)}
60
+
61
+
62
+ def get_user():
63
+ store = _request_store.get()
64
+ return (store or {}).get("user") or None
65
+
66
+
67
+ def set_sampled(value):
68
+ store = _store()
69
+ store["sampled"] = value
70
+
71
+
72
+ def is_sampled_out():
73
+ store = _request_store.get()
74
+ return bool(store) and store.get("sampled") is False
75
+
76
+
77
+ def now_ms():
78
+ return int(time.time() * 1000)
devlite/_errors.py ADDED
@@ -0,0 +1,111 @@
1
+ """Error-event enrichment: grouping fingerprint, source context, scrubbing.
2
+
3
+ Python port of errorEvent.js / fingerprint.js / sourceContext.js from the
4
+ Node SDK, so errors captured by either SDK group into the same issue shape.
5
+ """
6
+
7
+ import hashlib
8
+ import os
9
+ import re
10
+ import traceback
11
+
12
+ from ._context import now_ms
13
+ from ._scrub import scrub_deep
14
+
15
+ _NUMBER_RE = re.compile(r"\d+")
16
+ _FRAME_NUMBERS_RE = re.compile(r", line \d+")
17
+ _MAX_FINGERPRINT_FRAMES = 5
18
+ _SOURCE_CONTEXT_LINES = 3
19
+
20
+
21
+ def normalize_message(message):
22
+ if not message:
23
+ return ""
24
+ return re.sub(r"\s+", " ", _NUMBER_RE.sub("#", str(message))).strip()
25
+
26
+
27
+ def normalize_stack_frames(stack, max_frames=_MAX_FINGERPRINT_FRAMES):
28
+ """Strip line numbers (which change per-instance) but keep file + function."""
29
+ if not stack:
30
+ return ""
31
+ lines = [line for line in stack.splitlines() if line.strip().startswith("File ")]
32
+ frames = [_FRAME_NUMBERS_RE.sub("", line).strip() for line in lines[:max_frames]]
33
+ return "|".join(frames)
34
+
35
+
36
+ def compute_fingerprint(type_, message, stack, custom_key=None):
37
+ """Stable fingerprint so the SAME bug groups into a single issue.
38
+
39
+ Honors an explicit custom_key (manual grouping) over heuristics.
40
+ """
41
+ if custom_key:
42
+ basis = str(custom_key)
43
+ else:
44
+ basis = "{}::{}::{}".format(type_ or "Error", normalize_message(message), normalize_stack_frames(stack))
45
+ return hashlib.sha1(basis.encode("utf-8", errors="replace")).hexdigest()[:16]
46
+
47
+
48
+ def get_source_context(tb, context_lines=_SOURCE_CONTEXT_LINES):
49
+ """Read the actual lines of code around the crash, live from disk.
50
+
51
+ Returns a list of {line, content} dicts or None if the source is
52
+ unavailable (stdlib frames, missing files, etc.).
53
+ """
54
+ try:
55
+ frames = traceback.extract_tb(tb)
56
+ if not frames:
57
+ return None
58
+ frame = frames[-1] # innermost frame = where the exception was raised
59
+ filename = frame.filename
60
+ lineno = frame.lineno
61
+ if not filename or not os.path.isfile(filename):
62
+ return None
63
+ with open(filename, "r", encoding="utf-8", errors="replace") as fh:
64
+ lines = fh.read().splitlines()
65
+ start = max(0, lineno - 1 - context_lines)
66
+ end = min(len(lines), lineno + context_lines)
67
+ return [
68
+ {"line": i + 1, "content": lines[i]}
69
+ for i in range(start, end)
70
+ ]
71
+ except Exception:
72
+ return None
73
+
74
+
75
+ def format_stack(exc):
76
+ try:
77
+ return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
78
+ except Exception:
79
+ return str(exc)
80
+
81
+
82
+ def build_error_event(exc, config, breadcrumbs=None, user=None, extra=None, fatal=False):
83
+ """Assemble a fully-enriched error event — same shape as the Node SDK."""
84
+ name = type(exc).__name__ if exc is not None else "Error"
85
+ message = str(exc) or name
86
+ stack = format_stack(exc)
87
+
88
+ extra = dict(extra) if extra else {}
89
+ fingerprint = compute_fingerprint(name, message, stack, custom_key=extra.get("fingerprint"))
90
+
91
+ source_context = None
92
+ if config.get("capture_source_context", True) and exc is not None:
93
+ source_context = get_source_context(exc.__traceback__)
94
+
95
+ raw_event = {
96
+ "type": "error",
97
+ "fatal": fatal,
98
+ "name": name,
99
+ "message": message,
100
+ "stack": stack,
101
+ "fingerprint": fingerprint,
102
+ "sourceContext": source_context,
103
+ "user": user or None,
104
+ "breadcrumbs": breadcrumbs or [],
105
+ "extra": extra,
106
+ "timestamp": now_ms(),
107
+ }
108
+
109
+ if config.get("scrub_sensitive_data", True):
110
+ return scrub_deep(raw_event)
111
+ return raw_event
devlite/_flask.py ADDED
@@ -0,0 +1,115 @@
1
+ """Optional Flask instrumentation.
2
+
3
+ Call devlite.instrument_flask(app) after creating your Flask app to get:
4
+ - automatic request + slow_request events for every HTTP request
5
+ - unhandled exception capture with full enrichment (grouping, source
6
+ context, user impact, breadcrumbs)
7
+ - request-scoped user context (devlite.set_user() inside handlers)
8
+ - request breadcrumbs feeding the AI's root-cause explanation
9
+
10
+ Flask itself is an optional dependency (installed separately); importing
11
+ this module never requires it — the import happens lazily in
12
+ instrument_flask().
13
+ """
14
+
15
+ import time
16
+ from contextvars import ContextVar
17
+
18
+ from ._context import now_ms, run_with_context
19
+
20
+ _request_start = ContextVar("devlite_request_start", default=None)
21
+ _request_meta = ContextVar("devlite_request_meta", default=None)
22
+
23
+ SLOW_REQUEST_THRESHOLD_MS = 1000
24
+
25
+
26
+ def _record_start():
27
+ _request_start.set(time.time() * 1000)
28
+ _request_meta.set(None)
29
+
30
+
31
+ def _redact_headers(headers, redact_list):
32
+ out = {}
33
+ for key, value in headers.items():
34
+ out[key] = "[REDACTED]" if key.lower() in redact_list else value
35
+ return out
36
+
37
+
38
+ def instrument_flask(app, transport, context, config):
39
+ """Wire Flask request/exception hooks into the SDK transport."""
40
+ redact_list = config.get("redact_headers", ["authorization", "cookie", "x-api-key"])
41
+ capture_body = config.get("capture_body", False)
42
+ sample_rate = float(config.get("sample_rate", 1.0))
43
+ sample_every = 1.0 / sample_rate if sample_rate > 0 else float("inf")
44
+
45
+ import random
46
+
47
+ counter = [0]
48
+
49
+ @app.before_request
50
+ def _start():
51
+ run_with_context(lambda: None) # fresh request context
52
+ _record_start()
53
+ counter[0] += 1
54
+ sampled = sample_rate >= 1.0 or (counter[0] % int(sample_every) == 0)
55
+ from ._context import set_sampled
56
+
57
+ set_sampled(sampled)
58
+
59
+ @app.after_request
60
+ def _finish(response):
61
+ start = _request_start.get()
62
+ if start is None:
63
+ return response
64
+ duration_ms = int(now_ms() - start)
65
+ meta = _request_meta.get() or {}
66
+ path = meta.get("path") or (response.request.path if getattr(response, "request", None) else "/")
67
+ method = meta.get("method") or (response.request.method if getattr(response, "request", None) else "GET")
68
+ status = response.status_code
69
+
70
+ context.add_breadcrumb({"type": "request", "method": method, "path": path, "statusCode": status, "durationMs": duration_ms})
71
+
72
+ headers = None
73
+ if capture_body and getattr(response, "request", None):
74
+ headers = _redact_headers(response.request.headers, redact_list)
75
+
76
+ transport.enqueue({
77
+ "type": "request",
78
+ "method": method,
79
+ "path": path,
80
+ "statusCode": status,
81
+ "durationMs": duration_ms,
82
+ "headers": headers,
83
+ "timestamp": int(start),
84
+ })
85
+ if duration_ms > SLOW_REQUEST_THRESHOLD_MS:
86
+ transport.enqueue({
87
+ "type": "slow_request",
88
+ "method": method,
89
+ "path": path,
90
+ "durationMs": duration_ms,
91
+ "timestamp": int(start),
92
+ })
93
+ return response
94
+
95
+ @app.errorhandler(Exception)
96
+ def _on_error(exc):
97
+ from werkzeug.exceptions import HTTPException
98
+
99
+ if isinstance(exc, HTTPException) and exc.code and exc.code < 500:
100
+ return exc # 4xx are handled responses, not incidents
101
+
102
+ from ._context import get_user
103
+ from ._errors import build_error_event
104
+
105
+ breadcrumbs = context.snapshot().get("breadcrumbs", [])
106
+ event = build_error_event(
107
+ exc,
108
+ config,
109
+ breadcrumbs=breadcrumbs,
110
+ user=get_user(),
111
+ )
112
+ transport.enqueue(event)
113
+ raise exc
114
+
115
+ return app
devlite/_metrics.py ADDED
@@ -0,0 +1,207 @@
1
+ """Zero-dependency host telemetry sampler — Python port of systemMetrics.js.
2
+
3
+ Collects process CPU %, memory (process + system), CPU load, and — on
4
+ Linux, where /proc exposes it — network and disk throughput deltas. On
5
+ Windows, only CPU/memory/load are reported. Emits plain DevLite `metric`
6
+ events ready for the transport queue.
7
+ """
8
+
9
+ import os
10
+ import platform
11
+ import socket
12
+ import sys
13
+ import time
14
+
15
+ from ._context import now_ms
16
+
17
+ _last_cpu = os.times()
18
+ _last_cpu_at = time.time()
19
+ _last_network = None
20
+ _last_disk = None
21
+
22
+ _SYS_TICKS = os.sysconf("SC_CLK_TCK") if hasattr(os, "sysconf") else 100
23
+
24
+
25
+ def cpu_percent():
26
+ """% of a single core the process is using since the last call (0-100)."""
27
+ global _last_cpu, _last_cpu_at
28
+ now = time.time()
29
+ cur = os.times()
30
+ dt = max(1.0, now - _last_cpu_at)
31
+ user = (cur.user - _last_cpu.user) + (cur.children_user - _last_cpu.children_user)
32
+ system = (cur.system - _last_cpu.system) + (cur.children_system - _last_cpu.children_system)
33
+ _last_cpu = cur
34
+ _last_cpu_at = now
35
+ pct = ((user + system) / dt) * 100.0
36
+ return round(min(100.0, pct), 2)
37
+
38
+
39
+ def _process_rss_bytes():
40
+ """Resident set size in bytes, cross-platform, no dependencies."""
41
+ if sys.platform == "linux":
42
+ try:
43
+ with open("/proc/self/status", "r", encoding="utf-8", errors="replace") as fh:
44
+ for line in fh:
45
+ if line.startswith("VmRSS:"):
46
+ return int(line.split()[1]) * 1024
47
+ except Exception:
48
+ pass
49
+ elif sys.platform == "win32":
50
+ try:
51
+ import ctypes
52
+ from ctypes import wintypes
53
+
54
+ class PROCESS_MEMORY_COUNTERS(ctypes.Structure):
55
+ _fields_ = [
56
+ ("cb", wintypes.DWORD),
57
+ ("PageFaultCount", wintypes.DWORD),
58
+ ("PeakWorkingSetSize", ctypes.c_size_t),
59
+ ("WorkingSetSize", ctypes.c_size_t),
60
+ ("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
61
+ ("QuotaPagedPoolUsage", ctypes.c_size_t),
62
+ ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
63
+ ("QuotaNonPagedPoolUsage", ctypes.c_size_t),
64
+ ("PagefileUsage", ctypes.c_size_t),
65
+ ("PeakPagefileUsage", ctypes.c_size_t),
66
+ ]
67
+
68
+ counters = PROCESS_MEMORY_COUNTERS()
69
+ counters.cb = ctypes.sizeof(PROCESS_MEMORY_COUNTERS)
70
+ handle = ctypes.windll.kernel32.GetCurrentProcess()
71
+ if ctypes.windll.psapi.GetProcessMemoryInfo(handle, ctypes.byref(counters), counters.cb):
72
+ return counters.WorkingSetSize
73
+ except Exception:
74
+ pass
75
+ else: # macOS / BSD
76
+ try:
77
+ import resource
78
+
79
+ return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss * 1024
80
+ except Exception:
81
+ pass
82
+ return None
83
+
84
+
85
+ def memory_snapshot():
86
+ return {"rss": _process_rss_bytes()}
87
+
88
+
89
+ def _read_net_dev():
90
+ if platform.system() != "Linux":
91
+ return None
92
+ try:
93
+ rx = 0
94
+ tx = 0
95
+ with open("/proc/net/dev", "r", encoding="utf-8", errors="replace") as fh:
96
+ for line in fh.readlines()[2:]:
97
+ parts = line.split(":")
98
+ if len(parts) < 2:
99
+ continue
100
+ fields = parts[1].split()
101
+ if len(fields) < 9:
102
+ continue
103
+ try:
104
+ rx += int(fields[0])
105
+ tx += int(fields[8])
106
+ except ValueError:
107
+ pass
108
+ return {"rx": rx, "tx": tx}
109
+ except Exception:
110
+ return None
111
+
112
+
113
+ def _read_disk_stats():
114
+ if platform.system() != "Linux":
115
+ return None
116
+ try:
117
+ read_bytes = 0
118
+ write_bytes = 0
119
+ with open("/proc/diskstats", "r", encoding="utf-8", errors="replace") as fh:
120
+ for line in fh:
121
+ parts = line.split()
122
+ if len(parts) < 10:
123
+ continue
124
+ name = parts[2]
125
+ if name.startswith(("loop", "ram", "zram", "dm-")):
126
+ continue
127
+ try:
128
+ read_bytes += int(parts[5]) * 512
129
+ write_bytes += int(parts[9]) * 512
130
+ except ValueError:
131
+ pass
132
+ return {"readBytes": read_bytes, "writeBytes": write_bytes}
133
+ except Exception:
134
+ return None
135
+
136
+
137
+ def network_rates():
138
+ global _last_network
139
+ cur = _read_net_dev()
140
+ if not cur:
141
+ return None
142
+ if not _last_network:
143
+ _last_network = {"ts": time.time(), **cur}
144
+ return None
145
+ dt = max(1.0, time.time() - _last_network["ts"])
146
+ out = {
147
+ "rxBytesPerSec": int(max(0, (cur["rx"] - _last_network["rx"]) / dt)),
148
+ "txBytesPerSec": int(max(0, (cur["tx"] - _last_network["tx"]) / dt)),
149
+ }
150
+ _last_network = {"ts": time.time(), **cur}
151
+ return out
152
+
153
+
154
+ def disk_rates():
155
+ global _last_disk
156
+ cur = _read_disk_stats()
157
+ if not cur:
158
+ return None
159
+ if not _last_disk:
160
+ _last_disk = {"ts": time.time(), **cur}
161
+ return None
162
+ dt = max(1.0, time.time() - _last_disk["ts"])
163
+ out = {
164
+ "readBytesPerSec": int(max(0, (cur["readBytes"] - _last_disk["readBytes"]) / dt)),
165
+ "writeBytesPerSec": int(max(0, (cur["writeBytes"] - _last_disk["writeBytes"]) / dt)),
166
+ }
167
+ _last_disk = {"ts": time.time(), **cur}
168
+ return out
169
+
170
+
171
+ def collect_host_metrics(options=None):
172
+ """Collect one round of host metrics as DevLite metric events."""
173
+ options = options or {}
174
+ include_disk = options.get("include_disk", True)
175
+ include_network = options.get("include_network", True)
176
+ host = socket.gethostname()
177
+ base = {"type": "metric", "unit": "bytes", "tags": {"host": host}, "timestamp": _now_ms()}
178
+ metrics = []
179
+
180
+ metrics.append({**base, "name": "host.cpu.percent", "value": cpu_percent(), "unit": "percent"})
181
+
182
+ mem = memory_snapshot()
183
+ if mem.get("rss") is not None:
184
+ metrics.append({**base, "name": "host.memory.rss", "value": mem["rss"]})
185
+
186
+ if hasattr(os, "getloadavg"):
187
+ try:
188
+ load1, load5, load15 = os.getloadavg()
189
+ metrics.append({**base, "name": "host.cpu.load1", "value": load1, "unit": "load"})
190
+ metrics.append({**base, "name": "host.cpu.load5", "value": load5, "unit": "load"})
191
+ metrics.append({**base, "name": "host.cpu.load15", "value": load15, "unit": "load"})
192
+ except OSError:
193
+ pass
194
+
195
+ if include_network:
196
+ net = network_rates()
197
+ if net:
198
+ metrics.append({**base, "name": "host.network.rxBytesPerSec", "value": net["rxBytesPerSec"], "unit": "bytes/sec"})
199
+ metrics.append({**base, "name": "host.network.txBytesPerSec", "value": net["txBytesPerSec"], "unit": "bytes/sec"})
200
+
201
+ if include_disk:
202
+ disk = disk_rates()
203
+ if disk:
204
+ metrics.append({**base, "name": "host.disk.readBytesPerSec", "value": disk["readBytesPerSec"], "unit": "bytes/sec"})
205
+ metrics.append({**base, "name": "host.disk.writeBytesPerSec", "value": disk["writeBytesPerSec"], "unit": "bytes/sec"})
206
+
207
+ return metrics
devlite/_queue.py ADDED
@@ -0,0 +1,39 @@
1
+ """Bounded thread-safe FIFO queue for buffered telemetry events."""
2
+
3
+ from collections import deque
4
+ from threading import Lock
5
+
6
+
7
+ class EventQueue:
8
+ """Simple bounded FIFO queue.
9
+
10
+ When full, drops the oldest events first (better to lose old data than
11
+ crash the host app or grow memory unbounded) — same semantics as the
12
+ Node SDK's queue.js.
13
+ """
14
+
15
+ def __init__(self, max_queue_size=5000):
16
+ self.max_queue_size = max_queue_size
17
+ self._items = deque()
18
+ self._lock = Lock()
19
+ self.dropped_count = 0
20
+
21
+ def push(self, event):
22
+ with self._lock:
23
+ if len(self._items) >= self.max_queue_size:
24
+ self._items.popleft()
25
+ self.dropped_count += 1
26
+ self._items.append(event)
27
+
28
+ def drain(self, max_batch_size=None):
29
+ max_batch_size = max_batch_size if max_batch_size is not None else self.max_queue_size
30
+ with self._lock:
31
+ batch = []
32
+ while self._items and len(batch) < max_batch_size:
33
+ batch.append(self._items.popleft())
34
+ return batch
35
+
36
+ @property
37
+ def size(self):
38
+ with self._lock:
39
+ return len(self._items)
devlite/_scrub.py ADDED
@@ -0,0 +1,74 @@
1
+ """Sensitive-data scrubbing — a faithful Python port of devlite-sdk's scrub.js.
2
+
3
+ Automatically redacts common sensitive patterns from any string field before
4
+ it is sent off-host. Runs on every event regardless of config as a safety
5
+ net. Deliberately conservative: better to over-redact than leak a credential
6
+ into a third-party dashboard.
7
+ """
8
+
9
+ import re
10
+
11
+ PATTERNS = [
12
+ ("email", re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"), "[REDACTED_EMAIL]"),
13
+ ("credit_card", re.compile(r"\b(?:\d[ -]*?){13,16}\b"), "[REDACTED_CC]"),
14
+ ("jwt", re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"), "[REDACTED_JWT]"),
15
+ (
16
+ "api_key_assignment",
17
+ re.compile(r"(api[_-]?key|secret|token|password)\s*[:=]\s*['\"]?[\w\-\.]{8,}['\"]?", re.I),
18
+ r"\1=[REDACTED]",
19
+ ),
20
+ ("bearer_token", re.compile(r"Bearer\s+[\w\-\.]+", re.I), "Bearer [REDACTED]"),
21
+ ("aws_key", re.compile(r"AKIA[0-9A-Z]{16}"), "[REDACTED_AWS_KEY]"),
22
+ ]
23
+
24
+ SENSITIVE_KEY_NAMES = {
25
+ "password",
26
+ "pass",
27
+ "secret",
28
+ "token",
29
+ "apikey",
30
+ "api_key",
31
+ "authorization",
32
+ "cookie",
33
+ "ssn",
34
+ "creditcard",
35
+ "credit_card",
36
+ }
37
+
38
+ _MAX_DEPTH = 8
39
+
40
+
41
+ def scrub_string(value):
42
+ if not isinstance(value, str) or not value:
43
+ return value
44
+ for _, regex, replacement in PATTERNS:
45
+ value = regex.sub(replacement, value)
46
+ return value
47
+
48
+
49
+ def _is_sensitive_key(key):
50
+ normalized = re.sub(r"[^a-z]", "", str(key).lower())
51
+ return normalized in SENSITIVE_KEY_NAMES
52
+
53
+
54
+ def scrub_deep(value, depth=0):
55
+ """Recursively scrub strings and fully redact sensitive-keyed values."""
56
+ if depth > _MAX_DEPTH:
57
+ return value
58
+ if value is None:
59
+ return None
60
+ if isinstance(value, bool):
61
+ return value
62
+ if isinstance(value, (int, float)):
63
+ return value
64
+ if isinstance(value, str):
65
+ return scrub_string(value)
66
+ if isinstance(value, (list, tuple, set)):
67
+ return [scrub_deep(v, depth + 1) for v in value]
68
+ if isinstance(value, dict):
69
+ out = {}
70
+ for key, val in value.items():
71
+ out[key] = "[REDACTED]" if _is_sensitive_key(key) else scrub_deep(val, depth + 1)
72
+ return out
73
+ # Fallback for non-JSON types (datetimes, objects, ...) — keep safe, plain.
74
+ return str(value)
devlite/_transport.py ADDED
@@ -0,0 +1,144 @@
1
+ """Batch transport with retry/backoff and gzip — Python port of transport.js.
2
+
3
+ Uses only the standard library (urllib), so the SDK has zero runtime
4
+ dependencies. Batches events into {apiKey, service, environment, release,
5
+ events[]} payloads and POSTs them to the DevLite ingest endpoint exactly
6
+ like the Node SDK.
7
+ """
8
+
9
+ import gzip
10
+ import json
11
+ import threading
12
+ import time
13
+ import urllib.error
14
+ import urllib.request
15
+
16
+ from ._queue import EventQueue
17
+
18
+ COHERENT_SAMPLED_TYPES = {
19
+ "request",
20
+ "slow_request",
21
+ "error",
22
+ "message",
23
+ "metric",
24
+ "log",
25
+ "span",
26
+ }
27
+
28
+
29
+ def _json_default(obj):
30
+ return str(obj)
31
+
32
+
33
+ class Transport:
34
+ def __init__(self, config):
35
+ self.config = config
36
+ self.queue = EventQueue(max_queue_size=config.get("max_queue_size", 5000))
37
+ self._lock = threading.Lock()
38
+ self._closed = False
39
+ self._flusher = None
40
+
41
+ if config.get("flush_interval_ms", 5000) > 0:
42
+ self._flusher = threading.Thread(
43
+ target=self._flusher_loop, daemon=True, name="devlite-transport"
44
+ )
45
+ self._flusher.start()
46
+
47
+ # --- public -----------------------------------------------------------
48
+
49
+ def enqueue(self, event):
50
+ if self._closed:
51
+ return
52
+ if event.get("type") in COHERENT_SAMPLED_TYPES:
53
+ from ._context import is_sampled_out
54
+
55
+ if is_sampled_out():
56
+ return
57
+ self.queue.push(event)
58
+ if self.queue.size >= self.config.get("max_batch_size", 100):
59
+ self.flush()
60
+
61
+ def flush(self):
62
+ if self._closed:
63
+ return
64
+ batch = self.queue.drain(self.config.get("max_batch_size", 100))
65
+ if not batch:
66
+ return
67
+ self._send_with_retry(batch)
68
+
69
+ def close(self):
70
+ self._closed = True
71
+ self.flush()
72
+
73
+ def _flusher_loop(self):
74
+ interval = self.config.get("flush_interval_ms", 5000) / 1000.0
75
+ while not self._closed:
76
+ time.sleep(interval)
77
+ try:
78
+ self.flush()
79
+ except Exception:
80
+ # never let background delivery kill the host process
81
+ pass
82
+
83
+ # --- internals --------------------------------------------------------
84
+
85
+ def _payload(self, batch):
86
+ return {
87
+ "apiKey": self.config["api_key"],
88
+ "service": self.config.get("service_name"),
89
+ "environment": self.config.get("environment"),
90
+ "release": self.config.get("release"),
91
+ "events": batch,
92
+ }
93
+
94
+ def _send_with_retry(self, batch, attempt=1):
95
+ try:
96
+ self._send(batch)
97
+ except Exception as err: # noqa: BLE001
98
+ if attempt < self.config.get("max_retries", 3):
99
+ delay = self.config.get("retry_base_delay_ms", 500) * (2 ** (attempt - 1)) / 1000.0
100
+ time.sleep(delay)
101
+ self._send_with_retry(batch, attempt + 1)
102
+ return
103
+ self._handle_error(err, batch)
104
+
105
+ def _send(self, batch):
106
+ body = json.dumps(self._payload(batch), default=_json_default).encode("utf-8")
107
+ headers = {
108
+ "Content-Type": "application/json",
109
+ "User-Agent": self.config.get("user_agent", "devlite-python/0.1.0"),
110
+ }
111
+
112
+ # Marks this request as the SDK's own delivery so an instrumented
113
+ # ingest server never records it as app telemetry (feedback loop).
114
+ headers["X-DevLite-Transport"] = "1"
115
+
116
+ if self.config.get("gzip", True):
117
+ body = gzip.compress(body)
118
+ headers["Content-Encoding"] = "gzip"
119
+
120
+ req = urllib.request.Request(
121
+ self.config.get("endpoint"),
122
+ data=body,
123
+ headers=headers,
124
+ method="POST",
125
+ )
126
+ timeout = self.config.get("request_timeout_ms", 10000) / 1000.0
127
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
128
+ if resp.status >= 400:
129
+ raise RuntimeError("DevLite ingest responded with status {}".format(resp.status))
130
+
131
+ def _handle_error(self, err, batch):
132
+ if self.config.get("debug"):
133
+ print(
134
+ "[DevLite] failed to send {} event(s) after {} attempts: {}".format(
135
+ len(batch), self.config.get("max_retries", 3), err
136
+ )
137
+ )
138
+ on_error = self.config.get("on_error")
139
+ if callable(on_error):
140
+ try:
141
+ on_error(err)
142
+ except Exception:
143
+ # never let a user callback crash the SDK
144
+ pass
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: devlite
3
+ Version: 0.1.0
4
+ Summary: DevLite Python SDK — AI-powered observability with a 2-line integration. Automatic error grouping, source context, user impact tracking, and sensitive-data scrubbing built in.
5
+ License: MIT
6
+ Project-URL: Homepage, https://devlite.io
7
+ Project-URL: Repository, https://github.com/Ishimwe-Kevin/devlite-app
8
+ Keywords: observability,monitoring,apm,error-tracking,error-monitoring,logging,ai,devlite
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Debuggers
15
+ Classifier: Topic :: System :: Monitoring
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Provides-Extra: flask
20
+ Requires-Dist: flask>=2.0; extra == "flask"
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # devlite — Python SDK
26
+
27
+ AI-powered observability for Python. Add two lines, get automatic request tracking, error capture, error grouping, source context, user tracking, and slow-endpoint detection — no config files, no manual instrumentation, **zero runtime dependencies**.
28
+
29
+ This is the Python counterpart of [`@devlite/nodejs`](https://www.npmjs.com/package/@devlite/nodejs) — both SDKs speak the same batch protocol to the [DevLite ingest API](https://github.com/Ishimwe-Kevin/devlite-app).
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install devlite # core (Flask optional)
35
+ pip install "devlite[flask]" # + Flask instrumentation
36
+ ```
37
+
38
+ ## 60-second quickstart (Flask)
39
+
40
+ ```python
41
+ from flask import Flask
42
+ import devlite
43
+
44
+ devlite.init(api_key="dl_live_xxxxx")
45
+
46
+ app = Flask(__name__)
47
+ devlite.instrument_flask(app)
48
+ ```
49
+
50
+ That's it — every HTTP request, slow endpoint, and unhandled exception is now captured automatically, with:
51
+
52
+ - **Grouped errors** — the same bug occurring 1,000 times shows up as one issue, not 1,000
53
+ - **Real source code context** — the actual lines around the crash, read live from disk
54
+ - **Automatic sensitive-data scrubbing** — emails, tokens, passwords redacted before anything leaves your process
55
+ - **User impact tracking** — know exactly which users hit which bugs
56
+
57
+ Point `api_key`/`endpoint` at your own DevLite ingest instance (Supabase-backed) to start seeing data.
58
+
59
+ ## Manual capture API
60
+
61
+ ```python
62
+ # Capture a handled error with extra context
63
+ devlite.capture_error(err, {"userId": "123", "action": "checkout"})
64
+ devlite.capture_error(err, {"fingerprint": "payment-timeout"}) # force grouping key
65
+
66
+ # Non-error event
67
+ devlite.capture_message("Payment retried after timeout", "warning")
68
+
69
+ # Custom metric — feeds forecasting/anomaly detection
70
+ devlite.report_metric("order.total", 42.5, unit="USD", tags={"region": "lagos"})
71
+
72
+ # Structured log line
73
+ devlite.capture_log("checkout completed", "info", {"orderId": "ord_123", "durationMs": 250})
74
+
75
+ # Trace a unit of work
76
+ span = devlite.start_span("checkout.process", {"trace_id": "abc"})
77
+ span.end("ok") # "ok" | "error"
78
+
79
+ # Tell DevLite about a deployment (powers before/after performance views)
80
+ devlite.report_deployment(version="v2.1.3", commit_sha="a1b2c3d")
81
+
82
+ # Breadcrumbs attach to the next captured error, improving AI root-cause
83
+ devlite.add_breadcrumb({"type": "business_event", "note": "user started checkout"})
84
+
85
+ # Tag all subsequent events
86
+ devlite.set_tag("region", "lagos")
87
+ ```
88
+
89
+ ## User impact tracking
90
+
91
+ `set_user()` is scoped to the current request (via `contextvars`, so concurrent requests never leak each other's identity):
92
+
93
+ ```python
94
+ from flask import request
95
+
96
+ @app.before_request
97
+ def identify():
98
+ devlite.set_user({"id": request.remote_addr, "email": request.headers.get("X-User-Email")})
99
+ ```
100
+
101
+ ## Configuration options
102
+
103
+ ```python
104
+ devlite.init({
105
+ "api_key": "dl_live_xxxxx", # required
106
+ "environment": "production", # default: $DEVLITE_ENVIRONMENT or "development"
107
+ "service_name": "payments-api", # default: $DEVLITE_SERVICE_NAME or current folder
108
+ "release": "v2.1.3", # e.g. git sha, shown in deployment views
109
+ "sample_rate": 1.0, # 0.0–1.0. Sampling is COHERENT: a sampled-out
110
+ # request drops its errors/spans/logs/metrics together.
111
+ "capture_body": False, # capture (redacted) request headers — off by default
112
+ "flush_interval_ms": 5000, # how often batched events are sent
113
+ "gzip": True, # compress request bodies (Content-Encoding: gzip)
114
+ "debug": False, # log SDK internals
115
+ "on_error": lambda err: print(err), # SDK-internal send failures
116
+ })
117
+ ```
118
+
119
+ All of `api_key`, `service_name`, `release`, and `endpoint` can also be set via environment variables (`DEVLITE_API_KEY`, `DEVLITE_SERVICE_NAME`, `DEVLITE_RELEASE`, `DEVLITE_ENDPOINT`).
120
+
121
+ ## Serverless / short-lived processes
122
+
123
+ Events are batched, so call `flush()` before your function returns:
124
+
125
+ ```python
126
+ devlite.flush()
127
+ ```
128
+
129
+ ## Graceful shutdown
130
+
131
+ The SDK flushes remaining events on interpreter exit (`atexit`). If you manage shutdown yourself:
132
+
133
+ ```python
134
+ devlite.close()
135
+ ```
136
+
137
+ ## What makes this competitive
138
+
139
+ - **Automatic error grouping** — the same underlying bug, however many times it fires, is fingerprinted and grouped into one issue, instead of flooding your dashboard with duplicates.
140
+ - **Source code context** — every captured error includes the actual lines of code around the crash, read live from disk, not just a bare stack trace.
141
+ - **Automatic sensitive-data scrubbing** — on by default. Emails, JWTs, bearer tokens, AWS keys, credit card numbers, and any field literally named `password`/`token`/`secret` are redacted before anything leaves your process.
142
+ - **Never blocks your app** — all sends are async (background thread), batched, and retried with backoff. If DevLite's backend is unreachable, your app keeps running.
143
+ - **Zero dependencies** — pure Python standard library.
144
+
145
+ ## Development
146
+
147
+ ```bash
148
+ pip install -e ".[flask,test]"
149
+ python -m pytest
150
+ ```
151
+
152
+ ## License
153
+
154
+ MIT
@@ -0,0 +1,15 @@
1
+ devlite/__init__.py,sha256=0zRVYTY5Yx1V6TUfO4jJxX_76tbUFT2WFOwx_qqWm6w,4423
2
+ devlite/_client.py,sha256=Z5i6IlANy2-cympISwvINjCmOSj0qnNRrYJWXcWv8EA,6230
3
+ devlite/_config.py,sha256=VCE_TLB-74EqahTkHFrAk8GxpGMxykl5vkiqbPc9C0s,2362
4
+ devlite/_context.py,sha256=ynGlqODptyP7wo6NpzZsmOQNeOqbUlaT0C4NyWCDTO0,1974
5
+ devlite/_errors.py,sha256=MzF0S0ifCUHOBxFRvcoYT6msPXtTj_3RtmUVt9M9phI,3681
6
+ devlite/_flask.py,sha256=gWTcVmlDxrpbLzxiP6s3rO0kJimYnRnGK2nWIuKTa50,3849
7
+ devlite/_metrics.py,sha256=XI-deM9JgIdrnNQs_T1XgDjNCXTGsHJqB9eW4uvCnCQ,7224
8
+ devlite/_queue.py,sha256=TAdWvUadmHvsgdtVcbK4lDBZLjTUkfSjJnyZ0grFsfE,1191
9
+ devlite/_scrub.py,sha256=NjNS1N5hILZ8wv35caoHUjCNZnQ0-_0PykWlg5KvjoQ,2281
10
+ devlite/_transport.py,sha256=6V_C49WJlgcKGExX6uyr06t20XnHerDgEqpPwy7eKFU,4576
11
+ devlite-0.1.0.dist-info/licenses/LICENSE,sha256=YEYgiCxTHR-xVnbS1--3YalDzZpmrPRFBGHFaDX6AGg,1068
12
+ devlite-0.1.0.dist-info/METADATA,sha256=nbwCiAHxZnrmMlLwEe4mEc3kT_7nv1jYcx-bco0AwdU,6308
13
+ devlite-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
14
+ devlite-0.1.0.dist-info/top_level.txt,sha256=w8bsDCX1j10mIkzzqMTjZb_vcu_ZNAyegLnv9N1Pe4Y,8
15
+ devlite-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ishvexa Hub
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ devlite