insider-python 0.1.2__py3-none-any.whl → 0.1.4__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.
insider/__init__.py CHANGED
@@ -6,6 +6,7 @@ Public API:
6
6
  insider.init(dsn=..., environment=..., release=..., ...)
7
7
  insider.capture_exception(exc, level="error", tags=..., extra=...)
8
8
  insider.capture_message("text", level="info", tags=..., extra=...)
9
+ insider.capture_perf("GET /api/users/", duration_ms=45, status_code=200)
9
10
  insider.flush(timeout=2.0)
10
11
  insider.close(timeout=2.0)
11
12
 
@@ -17,12 +18,15 @@ from ._version import __version__
17
18
  from .client import (
18
19
  Client,
19
20
  capture_exception,
21
+ capture_log,
20
22
  capture_message,
23
+ capture_perf,
21
24
  close,
22
25
  flush,
23
26
  init,
24
27
  )
25
28
  from .dsn import DSN, InvalidDSNError
29
+ from .integrations.logging import LoggingIntegration
26
30
 
27
31
  __all__ = [
28
32
  "Client",
@@ -30,8 +34,11 @@ __all__ = [
30
34
  "InvalidDSNError",
31
35
  "__version__",
32
36
  "capture_exception",
37
+ "capture_log",
33
38
  "capture_message",
39
+ "capture_perf",
34
40
  "close",
35
41
  "flush",
36
42
  "init",
43
+ "LoggingIntegration",
37
44
  ]
insider/_envelope.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """
2
- Beacon envelope construction + size-budget enforcement.
2
+ Footprint envelope construction + size-budget enforcement.
3
3
 
4
4
  `build_envelope` is called from the capture functions in `client.py`. It
5
5
  takes the raw bits (kind, level, message, exception payload, scope,
@@ -58,7 +58,7 @@ def build_envelope(
58
58
  occurred_at: Optional[str] = None,
59
59
  commit_hash: Optional[str] = None,
60
60
  ) -> Dict[str, Any]:
61
- """Assemble the Beacon envelope. Pure: no I/O, no globals."""
61
+ """Assemble the Footprint envelope. Pure: no I/O, no globals."""
62
62
  body: Dict[str, Any] = dict(payload or {})
63
63
  if tags:
64
64
  body["tags"] = tags
insider/_footprint.py ADDED
@@ -0,0 +1,64 @@
1
+ """Build flat footprint payloads for beam ingest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+ from ._version import __version__
8
+ from .stacktrace import runtime_payload
9
+
10
+
11
+ def build_footprint_payload(
12
+ *,
13
+ request_id: Optional[str],
14
+ request_path: str,
15
+ request_method: Optional[str],
16
+ request_user: str = "anonymous",
17
+ request_body: Any = None,
18
+ response_body: Any = None,
19
+ response_time: float,
20
+ status_code: int,
21
+ system_logs: Optional[list] = None,
22
+ ip_address: Optional[str] = None,
23
+ user_agent: Optional[str] = None,
24
+ db_query_count: int = 0,
25
+ exception_block: Optional[Dict[str, Any]] = None,
26
+ environment: str = "production",
27
+ release: Optional[str] = None,
28
+ service_name: Optional[str] = None,
29
+ commit_hash: Optional[str] = None,
30
+ ) -> Dict[str, Any]:
31
+ runtime = runtime_payload(__version__)
32
+ stack_trace = None
33
+ exception_name = None
34
+ if exception_block:
35
+ exception_name = exception_block.get("type")
36
+ stack_trace = dict(exception_block)
37
+ if commit_hash:
38
+ stack_trace["commit_hash"] = commit_hash
39
+
40
+ body = request_body
41
+ if body is not None and not isinstance(body, (dict, list, str, int, float, bool)):
42
+ body = str(body)
43
+
44
+ return {
45
+ "request_id": request_id,
46
+ "request_user": request_user,
47
+ "request_path": request_path,
48
+ "request_body": body if body is not None else None,
49
+ "request_method": (request_method or "").lower() or None,
50
+ "response_body": response_body,
51
+ "response_time": float(response_time),
52
+ "status_code": status_code,
53
+ "system_logs": system_logs,
54
+ "ip_address": ip_address,
55
+ "user_agent": user_agent,
56
+ "db_query_count": db_query_count,
57
+ "exception_name": exception_name,
58
+ "stack_trace": stack_trace,
59
+ "service_name": service_name,
60
+ "environment": environment,
61
+ "language": runtime.get("language"),
62
+ "framework": runtime.get("framework"),
63
+ "release": release,
64
+ }
insider/_version.py CHANGED
@@ -5,4 +5,4 @@ lookup on every beacon. Bump this and `[project].version` together when
5
5
  cutting a release.
6
6
  """
7
7
 
8
- __version__ = "0.1.2"
8
+ __version__ = "0.1.4"
insider/client.py CHANGED
@@ -18,9 +18,12 @@ import atexit
18
18
  import os
19
19
  import subprocess
20
20
  import threading
21
- from typing import Any, Callable, Dict, Iterable, List, Optional
21
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Union
22
22
 
23
- from ._envelope import build_envelope, enforce_size_budget
23
+ import json
24
+
25
+ from ._envelope import MAX_ENVELOPE_BYTES, build_envelope, enforce_size_budget
26
+ from ._footprint import build_footprint_payload
24
27
  from ._version import __version__
25
28
  from .dsn import DSN, InvalidDSNError
26
29
  from .safety import debug, safe, set_debug
@@ -30,10 +33,27 @@ from .stacktrace import caller_source, exception_payload, runtime_payload
30
33
  from .transport import BackgroundTransport
31
34
 
32
35
 
33
- VALID_KINDS = {"error", "perf", "log", "custom"}
36
+ VALID_KINDS = {"error", "perf", "log", "custom", "request"}
34
37
  VALID_LEVELS = {"debug", "info", "warning", "error", "fatal"}
35
38
 
36
39
 
40
+ def _byte_len_footprint(obj: Dict[str, Any]) -> int:
41
+ try:
42
+ return len(json.dumps(obj, default=str, ensure_ascii=False).encode("utf-8"))
43
+ except Exception:
44
+ return 10**9
45
+
46
+ IntegrationLike = Union[Any, type]
47
+
48
+
49
+ def _setup_integrations(integrations: Sequence[IntegrationLike]) -> None:
50
+ for integration in integrations:
51
+ instance = integration() if isinstance(integration, type) else integration
52
+ setup_once = getattr(instance, "setup_once", None)
53
+ if callable(setup_once):
54
+ setup_once()
55
+
56
+
37
57
  # ---------------------------------------------------------------------------
38
58
  # DSN resolution
39
59
  # ---------------------------------------------------------------------------
@@ -78,9 +98,11 @@ class Client:
78
98
  transport_flush_timeout: float = 2.0,
79
99
  debug: bool = False,
80
100
  transport: Optional[BackgroundTransport] = None,
101
+ enable_logs: bool = False,
81
102
  ) -> None:
82
103
  set_debug(debug)
83
104
  self.dsn = dsn
105
+ self.enable_logs = bool(enable_logs)
84
106
  self.send_default_pii = bool(send_default_pii)
85
107
  self.before_send = before_send
86
108
  self.scrub_keys: List[str] = list(scrub_keys or [])
@@ -135,6 +157,10 @@ class Client:
135
157
  exception_block = exception_payload(
136
158
  exc, in_app_include=self.scope.static.in_app_include
137
159
  )
160
+ if self.scope.current_request() is not None:
161
+ self.scope.set_pending_exception(exception_block)
162
+ return self.scope.current_trace_id()
163
+
138
164
  payload: Dict[str, Any] = {
139
165
  "exception": exception_block,
140
166
  "runtime": runtime_payload(__version__),
@@ -142,6 +168,9 @@ class Client:
142
168
  request_ctx = self.scope.current_request()
143
169
  if request_ctx is not None:
144
170
  payload["request"] = request_ctx
171
+ breadcrumbs = self.scope.current_breadcrumbs()
172
+ if breadcrumbs:
173
+ payload["breadcrumbs"] = breadcrumbs
145
174
 
146
175
  envelope = build_envelope(
147
176
  kind="error",
@@ -150,7 +179,7 @@ class Client:
150
179
  source=self._source_from_exception(exception_block),
151
180
  environment=self.scope.static.environment,
152
181
  release=self.scope.static.release,
153
- trace_id=trace_id,
182
+ trace_id=trace_id or self.scope.current_trace_id(),
154
183
  commit_hash=self.commit_hash,
155
184
  payload=payload,
156
185
  tags=tags,
@@ -173,6 +202,19 @@ class Client:
173
202
  debug(f"capture_message expects str, got {type(message).__name__}")
174
203
  return None
175
204
  level = level if level in VALID_LEVELS else "info"
205
+
206
+ if self.scope.current_request() is not None:
207
+ from datetime import datetime, timezone
208
+
209
+ self.scope.add_request_log(
210
+ level=level,
211
+ message=message,
212
+ source=source or caller_source(skip=2),
213
+ timestamp=datetime.now(timezone.utc).isoformat(),
214
+ )
215
+ return self.scope.current_trace_id()
216
+
217
+ level = level if level in VALID_LEVELS else "info"
176
218
  kind = kind if kind in VALID_KINDS else "log"
177
219
 
178
220
  payload: Dict[str, Any] = {"runtime": runtime_payload(__version__)}
@@ -195,6 +237,146 @@ class Client:
195
237
  )
196
238
  return self._dispatch(envelope)
197
239
 
240
+ def capture_log(
241
+ self,
242
+ message: str,
243
+ *,
244
+ level: str = "info",
245
+ tags: Optional[Dict[str, Any]] = None,
246
+ extra: Optional[Dict[str, Any]] = None,
247
+ source: Optional[str] = None,
248
+ trace_id: Optional[str] = None,
249
+ ) -> Optional[str]:
250
+ """Record a structured log line (`kind=log`). Alias ergonomics over `capture_message`."""
251
+ return self.capture_message(
252
+ message,
253
+ level=level,
254
+ tags=tags,
255
+ extra=extra,
256
+ source=source,
257
+ trace_id=trace_id,
258
+ kind="log",
259
+ )
260
+
261
+ def capture_request(
262
+ self,
263
+ *,
264
+ duration_ms: float,
265
+ op: str,
266
+ status_code: Optional[int] = None,
267
+ method: Optional[str] = None,
268
+ trace_id: Optional[str] = None,
269
+ tags: Optional[Dict[str, Any]] = None,
270
+ extra: Optional[Dict[str, Any]] = None,
271
+ ) -> Optional[str]:
272
+ """Emit one HTTP request footprint to the beam endpoint."""
273
+ if not isinstance(duration_ms, (int, float)) or duration_ms < 0:
274
+ debug(f"capture_request expects duration_ms >= 0, got {duration_ms!r}")
275
+ return None
276
+ if not isinstance(op, str) or not op.strip():
277
+ debug("capture_request expects non-empty op str")
278
+ return None
279
+ code = status_code if status_code is not None else 200
280
+ if not isinstance(code, int) or not (100 <= code <= 599):
281
+ debug(f"capture_request invalid status_code: {status_code!r}")
282
+ return None
283
+
284
+ exception_block = self.scope.current_pending_exception()
285
+ request_ctx = self.scope.current_request() or {}
286
+ logs = self.scope.current_request_logs()
287
+ headers = request_ctx.get("headers") if isinstance(request_ctx.get("headers"), dict) else {}
288
+
289
+ footprint = build_footprint_payload(
290
+ request_id=trace_id or self.scope.current_trace_id(),
291
+ request_path=op.strip(),
292
+ request_method=method,
293
+ request_user=str(request_ctx.get("user") or "anonymous"),
294
+ request_body=request_ctx.get("body"),
295
+ response_time=float(duration_ms),
296
+ status_code=code,
297
+ system_logs=logs or None,
298
+ ip_address=request_ctx.get("ip") or request_ctx.get("ip_address") or headers.get("x-forwarded-for"),
299
+ user_agent=request_ctx.get("user_agent") or headers.get("user-agent"),
300
+ exception_block=exception_block,
301
+ environment=self.scope.static.environment,
302
+ release=self.scope.static.release,
303
+ commit_hash=self.commit_hash,
304
+ )
305
+ if tags:
306
+ footprint["_tags"] = tags
307
+ if extra:
308
+ footprint["_extra"] = extra
309
+ return self._dispatch_footprint(footprint)
310
+
311
+ def capture_perf(
312
+ self,
313
+ op: str,
314
+ duration_ms: float,
315
+ *,
316
+ status_code: Optional[int] = None,
317
+ method: Optional[str] = None,
318
+ level: str = "info",
319
+ tags: Optional[Dict[str, Any]] = None,
320
+ extra: Optional[Dict[str, Any]] = None,
321
+ source: str = "django.request",
322
+ trace_id: Optional[str] = None,
323
+ ) -> Optional[str]:
324
+ """
325
+ Record a performance timing beacon (`kind=perf`).
326
+
327
+ Used for HTTP request durations, slow queries, and other timing
328
+ signals. Does not create Issues on the server — perf rows live in
329
+ Beacons only. Pair with `trace_id` to link a perf row to an error
330
+ on the same request (see DjangoIntegration).
331
+ """
332
+ if not isinstance(op, str) or not op.strip():
333
+ debug("capture_perf expects non-empty op str")
334
+ return None
335
+ if not isinstance(duration_ms, (int, float)) or duration_ms < 0:
336
+ debug(f"capture_perf expects duration_ms >= 0, got {duration_ms!r}")
337
+ return None
338
+ if status_code is not None:
339
+ if not isinstance(status_code, int) or not (100 <= status_code <= 599):
340
+ debug(f"capture_perf invalid status_code: {status_code!r}")
341
+ return None
342
+ level = level if level in VALID_LEVELS else "info"
343
+
344
+ payload: Dict[str, Any] = {
345
+ "runtime": runtime_payload(__version__),
346
+ "duration_ms": float(duration_ms),
347
+ "op": op.strip(),
348
+ }
349
+ if status_code is not None:
350
+ payload["status_code"] = status_code
351
+ if method is not None:
352
+ payload["method"] = method
353
+
354
+ request_ctx = self.scope.current_request()
355
+ if request_ctx is not None:
356
+ payload["request"] = request_ctx
357
+
358
+ message = self._perf_summary_message(
359
+ method=method,
360
+ op=op.strip(),
361
+ status_code=status_code,
362
+ duration_ms=float(duration_ms),
363
+ )
364
+
365
+ envelope = build_envelope(
366
+ kind="perf",
367
+ level=level,
368
+ message=message,
369
+ source=source,
370
+ environment=self.scope.static.environment,
371
+ release=self.scope.static.release,
372
+ trace_id=trace_id or self.scope.current_trace_id(),
373
+ commit_hash=self.commit_hash,
374
+ payload=payload,
375
+ tags=tags,
376
+ extra=extra,
377
+ )
378
+ return self._dispatch(envelope)
379
+
198
380
  # ------------------------------------------------------------------
199
381
  # Lifecycle
200
382
  # ------------------------------------------------------------------
@@ -209,6 +391,32 @@ class Client:
209
391
  # Internal helpers
210
392
  # ------------------------------------------------------------------
211
393
 
394
+ def _dispatch_footprint(self, footprint: Dict[str, Any]) -> Optional[str]:
395
+ """Scrub → before_send → size budget → transport submit."""
396
+ scrubbed = dict(footprint)
397
+ scrubbed.pop("_tags", None)
398
+ scrubbed.pop("_extra", None)
399
+ if self.before_send is not None:
400
+ try:
401
+ wrapped = {"payload": scrubbed, **scrubbed}
402
+ result = self.before_send(wrapped) # type: ignore[assignment]
403
+ except Exception as exc:
404
+ debug(f"before_send raised {type(exc).__name__}: {exc}; dropping footprint")
405
+ return None
406
+ if result is None:
407
+ return None
408
+ if isinstance(result, dict) and "payload" in result:
409
+ scrubbed = result["payload"]
410
+ elif isinstance(result, dict):
411
+ scrubbed = result
412
+
413
+ if _byte_len_footprint(scrubbed) > MAX_ENVELOPE_BYTES:
414
+ debug("footprint exceeds size budget; dropping")
415
+ return None
416
+
417
+ accepted = self.transport.submit(scrubbed)
418
+ return scrubbed.get("request_id") if accepted else None
419
+
212
420
  def _dispatch(self, envelope: Dict[str, Any]) -> Optional[str]:
213
421
  """Scrub → before_send → size budget → transport submit."""
214
422
  envelope["payload"] = scrub(envelope.get("payload"), extra_keys=self.scrub_keys)
@@ -237,6 +445,24 @@ class Client:
237
445
  return tail.get("module") or tail.get("function")
238
446
  return None
239
447
 
448
+ @staticmethod
449
+ def _perf_summary_message(
450
+ *,
451
+ method: Optional[str],
452
+ op: str,
453
+ status_code: Optional[int],
454
+ duration_ms: float,
455
+ ) -> str:
456
+ """Short list-view label, e.g. `GET /api/users/ 200 45ms`."""
457
+ parts: List[str] = []
458
+ if method:
459
+ parts.append(method)
460
+ parts.append(op)
461
+ if status_code is not None:
462
+ parts.append(str(status_code))
463
+ parts.append(f"{duration_ms:.0f}ms")
464
+ return " ".join(parts)
465
+
240
466
 
241
467
  # ---------------------------------------------------------------------------
242
468
  # Module-level facade
@@ -259,6 +485,8 @@ def _set_active(client: Optional[Client]) -> None:
259
485
  @safe
260
486
  def init(
261
487
  dsn: Optional[str] = None,
488
+ *,
489
+ integrations: Optional[Sequence[IntegrationLike]] = None,
262
490
  **kwargs: Any,
263
491
  ) -> Optional[Client]:
264
492
  """
@@ -268,6 +496,9 @@ def init(
268
496
  Calling `init` a second time is allowed but logs a warning and
269
497
  closes the previous client first. The new client becomes the
270
498
  process-global one.
499
+
500
+ Pass framework integrations via `integrations=[...]`. Each integration's
501
+ `setup_once()` runs after the client is active.
271
502
  """
272
503
  global _active_client
273
504
  raw = _resolve_dsn_string(dsn)
@@ -280,6 +511,8 @@ def init(
280
511
  debug(f"invalid DSN: {exc}; entering disabled mode")
281
512
  return None
282
513
 
514
+ integration_list = list(integrations or [])
515
+
283
516
  with _init_lock:
284
517
  if _active_client is not None:
285
518
  debug("re-initializing; closing previous client")
@@ -287,9 +520,11 @@ def init(
287
520
  _active_client.close()
288
521
  except Exception as exc:
289
522
  debug(f"previous client close failed: {exc}")
290
- client = Client(parsed, **kwargs)
523
+ enable_logs = bool(kwargs.pop("enable_logs", False))
524
+ client = Client(parsed, enable_logs=enable_logs, **kwargs)
291
525
  _set_active(client)
292
526
 
527
+ _setup_integrations(integration_list)
293
528
  atexit.register(_atexit_close)
294
529
  return client
295
530
 
@@ -347,6 +582,83 @@ def capture_message(
347
582
  )
348
583
 
349
584
 
585
+ @safe
586
+ def capture_log(
587
+ message: str,
588
+ *,
589
+ level: str = "info",
590
+ tags: Optional[Dict[str, Any]] = None,
591
+ extra: Optional[Dict[str, Any]] = None,
592
+ source: Optional[str] = None,
593
+ trace_id: Optional[str] = None,
594
+ ) -> Optional[str]:
595
+ client = _client()
596
+ if client is None:
597
+ return None
598
+ return client.capture_log(
599
+ message,
600
+ level=level,
601
+ tags=tags,
602
+ extra=extra,
603
+ source=source,
604
+ trace_id=trace_id,
605
+ )
606
+
607
+
608
+ @safe
609
+ def capture_request(
610
+ *,
611
+ duration_ms: float,
612
+ op: str,
613
+ status_code: Optional[int] = None,
614
+ method: Optional[str] = None,
615
+ trace_id: Optional[str] = None,
616
+ tags: Optional[Dict[str, Any]] = None,
617
+ extra: Optional[Dict[str, Any]] = None,
618
+ ) -> Optional[str]:
619
+ client = _client()
620
+ if client is None:
621
+ return None
622
+ return client.capture_request(
623
+ duration_ms=duration_ms,
624
+ op=op,
625
+ status_code=status_code,
626
+ method=method,
627
+ trace_id=trace_id,
628
+ tags=tags,
629
+ extra=extra,
630
+ )
631
+
632
+
633
+ @safe
634
+ def capture_perf(
635
+ op: str,
636
+ duration_ms: float,
637
+ *,
638
+ status_code: Optional[int] = None,
639
+ method: Optional[str] = None,
640
+ level: str = "info",
641
+ tags: Optional[Dict[str, Any]] = None,
642
+ extra: Optional[Dict[str, Any]] = None,
643
+ source: str = "django.request",
644
+ trace_id: Optional[str] = None,
645
+ ) -> Optional[str]:
646
+ client = _client()
647
+ if client is None:
648
+ return None
649
+ return client.capture_perf(
650
+ op,
651
+ duration_ms,
652
+ status_code=status_code,
653
+ method=method,
654
+ level=level,
655
+ tags=tags,
656
+ extra=extra,
657
+ source=source,
658
+ trace_id=trace_id,
659
+ )
660
+
661
+
350
662
  @safe
351
663
  def flush(timeout: Optional[float] = None) -> bool:
352
664
  client = _client()
@@ -13,6 +13,7 @@ from typing import Any, Dict
13
13
  from django.apps import AppConfig
14
14
 
15
15
  from ... import init
16
+ from ...integrations.django import DjangoIntegration
16
17
  from ...safety import debug
17
18
 
18
19
 
@@ -57,4 +58,4 @@ class InsiderConfig(AppConfig):
57
58
  # env-var fallback in `init` still applies if Django's settings
58
59
  # didn't define it.
59
60
  dsn = kwargs.pop("dsn", None)
60
- init(dsn, **kwargs)
61
+ init(dsn, integrations=[DjangoIntegration()], **kwargs)