devlite 0.1.2__tar.gz → 0.1.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {devlite-0.1.2 → devlite-0.1.4}/PKG-INFO +1 -1
- {devlite-0.1.2 → devlite-0.1.4}/devlite/__init__.py +167 -159
- {devlite-0.1.2 → devlite-0.1.4}/devlite/_context.py +29 -0
- {devlite-0.1.2 → devlite-0.1.4}/devlite/_django.py +6 -13
- {devlite-0.1.2 → devlite-0.1.4}/devlite/_fastapi.py +3 -10
- {devlite-0.1.2 → devlite-0.1.4}/devlite/_flask.py +3 -9
- {devlite-0.1.2 → devlite-0.1.4}/devlite/_transport.py +144 -144
- {devlite-0.1.2 → devlite-0.1.4}/devlite.egg-info/PKG-INFO +1 -1
- {devlite-0.1.2 → devlite-0.1.4}/devlite.egg-info/SOURCES.txt +1 -0
- {devlite-0.1.2 → devlite-0.1.4}/pyproject.toml +37 -37
- devlite-0.1.4/tests/test_sampling.py +31 -0
- {devlite-0.1.2 → devlite-0.1.4}/LICENSE +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/README.md +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/devlite/_client.py +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/devlite/_config.py +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/devlite/_errors.py +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/devlite/_metrics.py +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/devlite/_queue.py +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/devlite/_scrub.py +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/devlite.egg-info/dependency_links.txt +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/devlite.egg-info/requires.txt +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/devlite.egg-info/top_level.txt +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/setup.cfg +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/tests/test_context.py +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/tests/test_django.py +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/tests/test_errors.py +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/tests/test_fastapi.py +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/tests/test_queue.py +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/tests/test_scrub.py +0 -0
- {devlite-0.1.2 → devlite-0.1.4}/tests/test_transport.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlite
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
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
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://devlite.io
|
|
@@ -1,159 +1,167 @@
|
|
|
1
|
-
"""DevLite Python SDK — AI-powered observability with a 2-line integration.
|
|
2
|
-
|
|
3
|
-
import devlite
|
|
4
|
-
|
|
5
|
-
devlite.init(
|
|
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
|
-
instrument_fastapi, instrument_django, flush, close
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
from ._client import Client, HostMetricsHandle, Span
|
|
19
|
-
from ._context import run_with_context
|
|
20
|
-
|
|
21
|
-
__version__ = "0.1.
|
|
22
|
-
|
|
23
|
-
_client = None
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def init(options=None):
|
|
27
|
-
"""Initialize DevLite. The only required call.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
_client
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
"
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
"
|
|
149
|
-
"
|
|
150
|
-
"
|
|
151
|
-
"
|
|
152
|
-
"
|
|
153
|
-
"
|
|
154
|
-
"
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
|
|
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
|
+
instrument_fastapi, instrument_django, flush, close
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from ._client import Client, HostMetricsHandle, Span
|
|
19
|
+
from ._context import run_with_context
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.4"
|
|
22
|
+
|
|
23
|
+
_client = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def init(options=None, **kwargs):
|
|
27
|
+
"""Initialize DevLite. The only required call.
|
|
28
|
+
|
|
29
|
+
Accepts either a dict (`init({"api_key": "..."})`) or keyword
|
|
30
|
+
arguments (`init(api_key="...")`); kwargs take precedence when both
|
|
31
|
+
are passed.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
options: dict with keys like api_key, environment, service_name,
|
|
35
|
+
release, sample_rate, gzip, debug, endpoint, on_error, ...
|
|
36
|
+
**kwargs: the same options passed as keyword arguments.
|
|
37
|
+
`api_key` is required (or set DEVLITE_API_KEY env var).
|
|
38
|
+
"""
|
|
39
|
+
global _client
|
|
40
|
+
if _client is not None:
|
|
41
|
+
import warnings
|
|
42
|
+
|
|
43
|
+
warnings.warn("[DevLite] init() called more than once — ignoring subsequent call.")
|
|
44
|
+
return
|
|
45
|
+
opts = dict(options or {})
|
|
46
|
+
if kwargs:
|
|
47
|
+
opts.update(kwargs)
|
|
48
|
+
_client = Client(opts or {})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _require_client():
|
|
52
|
+
if _client is None:
|
|
53
|
+
raise RuntimeError("[DevLite] SDK not initialized. Call devlite.init(api_key=...) first.")
|
|
54
|
+
return _client
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def capture_error(error, extra=None):
|
|
58
|
+
"""Capture a handled error with optional extra context."""
|
|
59
|
+
_require_client().capture_error(error, extra=extra)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def capture_message(message, level="info", extra=None):
|
|
63
|
+
"""Capture a non-error event, e.g. a business-logic message."""
|
|
64
|
+
_require_client().capture_message(message, level=level, extra=extra)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def report_metric(name, value, unit=None, tags=None):
|
|
68
|
+
"""Report a metric data point (feeds forecasting/anomaly detection)."""
|
|
69
|
+
_require_client().report_metric(name, value, unit=unit, tags=tags)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def capture_log(message, level="info", fields=None):
|
|
73
|
+
"""Capture a structured log line with arbitrary fields."""
|
|
74
|
+
_require_client().capture_log(message, level=level, fields=fields)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def start_span(name, options=None):
|
|
78
|
+
"""Start a trace span; returns an object with id/trace_id and end(status)."""
|
|
79
|
+
return _require_client().start_span(name, options=options)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def report_span(name, trace_id=None, parent_id=None, tags=None, start_time=None, end_time=None, duration_ms=None, status="ok"):
|
|
83
|
+
"""Report a finished span if you track timing yourself."""
|
|
84
|
+
_require_client().report_span(
|
|
85
|
+
name,
|
|
86
|
+
trace_id=trace_id,
|
|
87
|
+
parent_id=parent_id,
|
|
88
|
+
tags=tags,
|
|
89
|
+
start_time=start_time,
|
|
90
|
+
end_time=end_time,
|
|
91
|
+
duration_ms=duration_ms,
|
|
92
|
+
status=status,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def report_deployment(version=None, commit_sha=None, notes=None):
|
|
97
|
+
"""Report a deployment — powers performance before/after deployment views."""
|
|
98
|
+
_require_client().report_deployment(version=version, commit_sha=commit_sha, notes=notes)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def add_breadcrumb(entry):
|
|
102
|
+
"""Add a manual breadcrumb (e.g. 'user started checkout') for richer AI context."""
|
|
103
|
+
_require_client().add_breadcrumb(entry)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def set_tag(key, value):
|
|
107
|
+
"""Tag all subsequent events, e.g. set_tag('region', 'lagos')."""
|
|
108
|
+
_require_client().set_tag(key, value)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def set_user(user):
|
|
112
|
+
"""Identify the user for the CURRENT request (scoped to the request)."""
|
|
113
|
+
_require_client().set_user(user)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def start_host_metrics(interval_ms=15000):
|
|
117
|
+
"""Start sampling host CPU/memory (plus network/disk on Linux) as metrics."""
|
|
118
|
+
return _require_client().start_host_metrics(interval_ms=interval_ms)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def instrument_flask(app):
|
|
122
|
+
"""Wire automatic request/error/user tracking into a Flask app."""
|
|
123
|
+
return _require_client().instrument_flask(app)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def instrument_fastapi(app):
|
|
127
|
+
"""Wire automatic request/error/user tracking into a FastAPI app."""
|
|
128
|
+
return _require_client().instrument_fastapi(app)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def instrument_django():
|
|
132
|
+
"""Insert DevLite's middleware at the top of Django's MIDDLEWARE setting."""
|
|
133
|
+
return _require_client().instrument_django()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def flush():
|
|
137
|
+
"""Force-send whatever is currently queued (useful in serverless)."""
|
|
138
|
+
_require_client().flush()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def close():
|
|
142
|
+
"""Cleanly stop the SDK — flushes remaining events."""
|
|
143
|
+
if _client is not None:
|
|
144
|
+
_client.close()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
__all__ = [
|
|
148
|
+
"__version__",
|
|
149
|
+
"init",
|
|
150
|
+
"capture_error",
|
|
151
|
+
"capture_message",
|
|
152
|
+
"report_metric",
|
|
153
|
+
"capture_log",
|
|
154
|
+
"start_span",
|
|
155
|
+
"report_span",
|
|
156
|
+
"report_deployment",
|
|
157
|
+
"add_breadcrumb",
|
|
158
|
+
"set_tag",
|
|
159
|
+
"set_user",
|
|
160
|
+
"run_with_context",
|
|
161
|
+
"start_host_metrics",
|
|
162
|
+
"instrument_flask",
|
|
163
|
+
"instrument_fastapi",
|
|
164
|
+
"instrument_django",
|
|
165
|
+
"flush",
|
|
166
|
+
"close",
|
|
167
|
+
]
|
|
@@ -14,6 +14,35 @@ MAX_BREADCRUMBS = 20
|
|
|
14
14
|
_request_store = contextvars.ContextVar("devlite_request_store", default=None)
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
class CoherentSampler:
|
|
18
|
+
"""Bresenham-style coherent sampler for request telemetry.
|
|
19
|
+
|
|
20
|
+
Keeps almost exactly rate*N of N requests, evenly spaced, for any rate in
|
|
21
|
+
(0, 1). Each decision adds `rate` to a running accumulator and fires
|
|
22
|
+
"keep" whenever the accumulator crosses 1.0 (subtracting 1.0 afterward).
|
|
23
|
+
The previous `int(1.0 / rate)` counter+modulo scheme collapsed for any
|
|
24
|
+
rate in (0.5, 1.0) — 1.0/rate truncated to 1, so every request was kept.
|
|
25
|
+
|
|
26
|
+
Callers drive it from a single middleware/request path (not thread-safe).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, rate=1.0):
|
|
30
|
+
self.rate = rate
|
|
31
|
+
self._accum = 0.0
|
|
32
|
+
|
|
33
|
+
def decide(self):
|
|
34
|
+
rate = self.rate
|
|
35
|
+
if rate >= 1.0:
|
|
36
|
+
return True
|
|
37
|
+
if rate <= 0.0:
|
|
38
|
+
return False
|
|
39
|
+
self._accum += rate
|
|
40
|
+
if self._accum >= 1.0:
|
|
41
|
+
self._accum -= 1.0
|
|
42
|
+
return True
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
17
46
|
def new_trace_id():
|
|
18
47
|
"""A fresh 16-hex-char traceId (8 bytes), matching the Node SDK."""
|
|
19
48
|
return secrets.token_hex(8)
|
|
@@ -14,7 +14,7 @@ this module never requires it — the import happens lazily in
|
|
|
14
14
|
instrument_django().
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
-
from ._context import now_ms, reset_context
|
|
17
|
+
from ._context import CoherentSampler, now_ms, reset_context
|
|
18
18
|
|
|
19
19
|
SLOW_REQUEST_THRESHOLD_MS = 1000
|
|
20
20
|
|
|
@@ -42,22 +42,15 @@ class InstrumentDjangoMiddleware:
|
|
|
42
42
|
|
|
43
43
|
def __init__(self, get_response):
|
|
44
44
|
self.get_response = get_response
|
|
45
|
-
self.
|
|
46
|
-
self.sample_every = None
|
|
47
|
-
self.counter = 0
|
|
45
|
+
self.sampler = None
|
|
48
46
|
|
|
49
47
|
def _configure_sampling(self, config):
|
|
50
|
-
|
|
51
|
-
self.sample_rate = rate
|
|
52
|
-
self.sample_every = 1.0 / rate if 0 < rate < 1.0 else None
|
|
48
|
+
self.sampler = CoherentSampler(float(config.get("sample_rate", 1.0)))
|
|
53
49
|
|
|
54
50
|
def _sampled(self):
|
|
55
|
-
if self.
|
|
56
|
-
return False if self.sample_rate is not None else True
|
|
57
|
-
if self.sample_rate >= 1.0:
|
|
51
|
+
if self.sampler is None:
|
|
58
52
|
return True
|
|
59
|
-
self.
|
|
60
|
-
return self.counter % int(self.sample_every) == 0
|
|
53
|
+
return self.sampler.decide()
|
|
61
54
|
|
|
62
55
|
def __call__(self, request):
|
|
63
56
|
client = _active_client()
|
|
@@ -73,7 +66,7 @@ class InstrumentDjangoMiddleware:
|
|
|
73
66
|
|
|
74
67
|
trace_id = new_trace_id()
|
|
75
68
|
set_trace_id(trace_id)
|
|
76
|
-
if self.
|
|
69
|
+
if self.sampler is None:
|
|
77
70
|
self._configure_sampling(config)
|
|
78
71
|
set_sampled(self._sampled())
|
|
79
72
|
|
|
@@ -13,7 +13,7 @@ importing this module never requires them — the import happens lazily in
|
|
|
13
13
|
instrument_fastapi().
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
-
from ._context import now_ms, reset_context
|
|
16
|
+
from ._context import CoherentSampler, now_ms, reset_context
|
|
17
17
|
|
|
18
18
|
SLOW_REQUEST_THRESHOLD_MS = 1000
|
|
19
19
|
|
|
@@ -37,17 +37,10 @@ class _DevLiteASGIMiddleware:
|
|
|
37
37
|
self.context = context
|
|
38
38
|
self.config = config
|
|
39
39
|
rate = float(config.get("sample_rate", 1.0))
|
|
40
|
-
self.
|
|
41
|
-
self.sample_every = 1.0 / rate if 0 < rate < 1.0 else None
|
|
42
|
-
self.counter = 0
|
|
40
|
+
self.sampler = CoherentSampler(rate)
|
|
43
41
|
|
|
44
42
|
def _sampled(self):
|
|
45
|
-
|
|
46
|
-
return False
|
|
47
|
-
if self.sample_rate >= 1.0:
|
|
48
|
-
return True
|
|
49
|
-
self.counter += 1
|
|
50
|
-
return self.counter % int(self.sample_every) == 0
|
|
43
|
+
return self.sampler.decide()
|
|
51
44
|
|
|
52
45
|
async def __call__(self, scope, receive, send):
|
|
53
46
|
if scope["type"] != "http":
|
|
@@ -15,7 +15,7 @@ instrument_flask().
|
|
|
15
15
|
import time
|
|
16
16
|
from contextvars import ContextVar
|
|
17
17
|
|
|
18
|
-
from ._context import now_ms, reset_context
|
|
18
|
+
from ._context import CoherentSampler, now_ms, reset_context
|
|
19
19
|
|
|
20
20
|
_request_start = ContextVar("devlite_request_start", default=None)
|
|
21
21
|
_request_meta = ContextVar("devlite_request_meta", default=None)
|
|
@@ -40,11 +40,7 @@ def instrument_flask(app, transport, context, config):
|
|
|
40
40
|
redact_list = config.get("redact_headers", ["authorization", "cookie", "x-api-key"])
|
|
41
41
|
capture_body = config.get("capture_body", False)
|
|
42
42
|
sample_rate = float(config.get("sample_rate", 1.0))
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
import random
|
|
46
|
-
|
|
47
|
-
counter = [0]
|
|
43
|
+
sampler = CoherentSampler(sample_rate)
|
|
48
44
|
|
|
49
45
|
@app.before_request
|
|
50
46
|
def _start():
|
|
@@ -53,9 +49,7 @@ def instrument_flask(app, transport, context, config):
|
|
|
53
49
|
|
|
54
50
|
set_trace_id(new_trace_id())
|
|
55
51
|
_record_start()
|
|
56
|
-
|
|
57
|
-
sampled = sample_rate >= 1.0 or (counter[0] % int(sample_every) == 0)
|
|
58
|
-
set_sampled(sampled)
|
|
52
|
+
set_sampled(sampler.decide())
|
|
59
53
|
|
|
60
54
|
@app.after_request
|
|
61
55
|
def _finish(response):
|
|
@@ -1,144 +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.
|
|
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
|
|
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.4"),
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlite
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
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
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://devlite.io
|
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=68"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "devlite"
|
|
7
|
-
version = "0.1.
|
|
8
|
-
description = "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."
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
license = { text = "MIT" }
|
|
11
|
-
requires-python = ">=3.9"
|
|
12
|
-
keywords = ["observability", "monitoring", "apm", "error-tracking", "error-monitoring", "logging", "ai", "devlite"]
|
|
13
|
-
classifiers = [
|
|
14
|
-
"Development Status :: 4 - Beta",
|
|
15
|
-
"Intended Audience :: Developers",
|
|
16
|
-
"License :: OSI Approved :: MIT License",
|
|
17
|
-
"Operating System :: OS Independent",
|
|
18
|
-
"Programming Language :: Python :: 3",
|
|
19
|
-
"Topic :: Software Development :: Debuggers",
|
|
20
|
-
"Topic :: System :: Monitoring",
|
|
21
|
-
]
|
|
22
|
-
|
|
23
|
-
[project.urls]
|
|
24
|
-
Homepage = "https://devlite.io"
|
|
25
|
-
Repository = "https://github.com/Ishimwe-Kevin/devlite-app"
|
|
26
|
-
|
|
27
|
-
[project.optional-dependencies]
|
|
28
|
-
flask = ["flask>=2.0"]
|
|
29
|
-
fastapi = ["fastapi>=0.100"]
|
|
30
|
-
django = ["django>=4.0"]
|
|
31
|
-
test = ["pytest", "flask>=2.0", "fastapi>=0.100", "django>=4.0", "httpx>=0.27"]
|
|
32
|
-
|
|
33
|
-
[tool.setuptools]
|
|
34
|
-
packages = ["devlite"]
|
|
35
|
-
|
|
36
|
-
[tool.pytest.ini_options]
|
|
37
|
-
testpaths = ["tests"]
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "devlite"
|
|
7
|
+
version = "0.1.4"
|
|
8
|
+
description = "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."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
keywords = ["observability", "monitoring", "apm", "error-tracking", "error-monitoring", "logging", "ai", "devlite"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Debuggers",
|
|
20
|
+
"Topic :: System :: Monitoring",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://devlite.io"
|
|
25
|
+
Repository = "https://github.com/Ishimwe-Kevin/devlite-app"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
flask = ["flask>=2.0"]
|
|
29
|
+
fastapi = ["fastapi>=0.100"]
|
|
30
|
+
django = ["django>=4.0"]
|
|
31
|
+
test = ["pytest", "flask>=2.0", "fastapi>=0.100", "django>=4.0", "httpx>=0.27"]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools]
|
|
34
|
+
packages = ["devlite"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Coherent sampler accuracy tests.
|
|
2
|
+
|
|
3
|
+
Regression for the collapse above 0.5: the old `int(1.0 / rate)` counter +
|
|
4
|
+
modulo kept every request for any rate in (0.5, 1.0). The accumulator keeps
|
|
5
|
+
~rate*N of N, evenly spaced, with error < 1 sample.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
|
12
|
+
|
|
13
|
+
from devlite._context import CoherentSampler # noqa: E402
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_coherent_sampler_accuracy():
|
|
17
|
+
n = 100_000
|
|
18
|
+
rates = (0.1, 0.25, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99)
|
|
19
|
+
for rate in rates:
|
|
20
|
+
sampler = CoherentSampler(rate)
|
|
21
|
+
kept = sum(1 for _ in range(n) if sampler.decide())
|
|
22
|
+
want = n * rate
|
|
23
|
+
tol = n * 0.0001 # far looser than the true <1-sample error
|
|
24
|
+
assert abs(kept - want) <= tol, f"rate {rate}: kept {kept}/{n}, want ~{want:.1f}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_coherent_sampler_boundaries():
|
|
28
|
+
assert not CoherentSampler(0.0).decide()
|
|
29
|
+
assert not CoherentSampler(-0.5).decide()
|
|
30
|
+
assert CoherentSampler(1.0).decide()
|
|
31
|
+
assert CoherentSampler(2.0).decide()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|