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 +147 -0
- devlite/_client.py +184 -0
- devlite/_config.py +62 -0
- devlite/_context.py +78 -0
- devlite/_errors.py +111 -0
- devlite/_flask.py +115 -0
- devlite/_metrics.py +207 -0
- devlite/_queue.py +39 -0
- devlite/_scrub.py +74 -0
- devlite/_transport.py +144 -0
- devlite-0.1.0.dist-info/METADATA +154 -0
- devlite-0.1.0.dist-info/RECORD +15 -0
- devlite-0.1.0.dist-info/WHEEL +5 -0
- devlite-0.1.0.dist-info/licenses/LICENSE +21 -0
- devlite-0.1.0.dist-info/top_level.txt +1 -0
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,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
|