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.
Files changed (30) hide show
  1. {devlite-0.1.2 → devlite-0.1.4}/PKG-INFO +1 -1
  2. {devlite-0.1.2 → devlite-0.1.4}/devlite/__init__.py +167 -159
  3. {devlite-0.1.2 → devlite-0.1.4}/devlite/_context.py +29 -0
  4. {devlite-0.1.2 → devlite-0.1.4}/devlite/_django.py +6 -13
  5. {devlite-0.1.2 → devlite-0.1.4}/devlite/_fastapi.py +3 -10
  6. {devlite-0.1.2 → devlite-0.1.4}/devlite/_flask.py +3 -9
  7. {devlite-0.1.2 → devlite-0.1.4}/devlite/_transport.py +144 -144
  8. {devlite-0.1.2 → devlite-0.1.4}/devlite.egg-info/PKG-INFO +1 -1
  9. {devlite-0.1.2 → devlite-0.1.4}/devlite.egg-info/SOURCES.txt +1 -0
  10. {devlite-0.1.2 → devlite-0.1.4}/pyproject.toml +37 -37
  11. devlite-0.1.4/tests/test_sampling.py +31 -0
  12. {devlite-0.1.2 → devlite-0.1.4}/LICENSE +0 -0
  13. {devlite-0.1.2 → devlite-0.1.4}/README.md +0 -0
  14. {devlite-0.1.2 → devlite-0.1.4}/devlite/_client.py +0 -0
  15. {devlite-0.1.2 → devlite-0.1.4}/devlite/_config.py +0 -0
  16. {devlite-0.1.2 → devlite-0.1.4}/devlite/_errors.py +0 -0
  17. {devlite-0.1.2 → devlite-0.1.4}/devlite/_metrics.py +0 -0
  18. {devlite-0.1.2 → devlite-0.1.4}/devlite/_queue.py +0 -0
  19. {devlite-0.1.2 → devlite-0.1.4}/devlite/_scrub.py +0 -0
  20. {devlite-0.1.2 → devlite-0.1.4}/devlite.egg-info/dependency_links.txt +0 -0
  21. {devlite-0.1.2 → devlite-0.1.4}/devlite.egg-info/requires.txt +0 -0
  22. {devlite-0.1.2 → devlite-0.1.4}/devlite.egg-info/top_level.txt +0 -0
  23. {devlite-0.1.2 → devlite-0.1.4}/setup.cfg +0 -0
  24. {devlite-0.1.2 → devlite-0.1.4}/tests/test_context.py +0 -0
  25. {devlite-0.1.2 → devlite-0.1.4}/tests/test_django.py +0 -0
  26. {devlite-0.1.2 → devlite-0.1.4}/tests/test_errors.py +0 -0
  27. {devlite-0.1.2 → devlite-0.1.4}/tests/test_fastapi.py +0 -0
  28. {devlite-0.1.2 → devlite-0.1.4}/tests/test_queue.py +0 -0
  29. {devlite-0.1.2 → devlite-0.1.4}/tests/test_scrub.py +0 -0
  30. {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.2
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({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.1"
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 instrument_fastapi(app):
119
- """Wire automatic request/error/user tracking into a FastAPI app."""
120
- return _require_client().instrument_fastapi(app)
121
-
122
-
123
- def instrument_django():
124
- """Insert DevLite's middleware at the top of Django's MIDDLEWARE setting."""
125
- return _require_client().instrument_django()
126
-
127
-
128
- def flush():
129
- """Force-send whatever is currently queued (useful in serverless)."""
130
- _require_client().flush()
131
-
132
-
133
- def close():
134
- """Cleanly stop the SDK — flushes remaining events."""
135
- if _client is not None:
136
- _client.close()
137
-
138
-
139
- __all__ = [
140
- "__version__",
141
- "init",
142
- "capture_error",
143
- "capture_message",
144
- "report_metric",
145
- "capture_log",
146
- "start_span",
147
- "report_span",
148
- "report_deployment",
149
- "add_breadcrumb",
150
- "set_tag",
151
- "set_user",
152
- "run_with_context",
153
- "start_host_metrics",
154
- "instrument_flask",
155
- "instrument_fastapi",
156
- "instrument_django",
157
- "flush",
158
- "close",
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.sample_rate = None
46
- self.sample_every = None
47
- self.counter = 0
45
+ self.sampler = None
48
46
 
49
47
  def _configure_sampling(self, config):
50
- rate = float(config.get("sample_rate", 1.0))
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.sample_rate is None or self.sample_rate <= 0:
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.counter += 1
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.sample_rate is None:
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.sample_rate = rate
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
- if self.sample_rate <= 0:
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
- sample_every = 1.0 / sample_rate if sample_rate > 0 else float("inf")
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
- counter[0] += 1
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.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.2
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
@@ -23,5 +23,6 @@ tests/test_django.py
23
23
  tests/test_errors.py
24
24
  tests/test_fastapi.py
25
25
  tests/test_queue.py
26
+ tests/test_sampling.py
26
27
  tests/test_scrub.py
27
28
  tests/test_transport.py
@@ -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.2"
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