tinymonpy 0.2.0__tar.gz → 0.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinymonpy
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Tiny error monitoring SDK for Python.
5
5
  Author: tinymon
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tinymonpy"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Tiny error monitoring SDK for Python."
9
9
  requires-python = ">=3.8"
10
10
  license = { text = "MIT" }
@@ -26,6 +26,7 @@ from .stacktrace import parse_traceback as _parse_traceback # public for contra
26
26
  __all__ = [
27
27
  "init",
28
28
  "capture_exception",
29
+ "capture_exception_with_context",
29
30
  "capture_message",
30
31
  "set_user",
31
32
  "set_tag",
@@ -34,7 +35,7 @@ __all__ = [
34
35
  "_parse_traceback",
35
36
  ]
36
37
 
37
- __version__ = "0.2.0"
38
+ __version__ = "0.3.0"
38
39
 
39
40
  _client: Optional[Client] = None
40
41
 
@@ -72,6 +73,21 @@ def capture_exception(exc: BaseException) -> None:
72
73
  _client.capture_exception(exc)
73
74
 
74
75
 
76
+ def capture_exception_with_context(
77
+ exc: BaseException,
78
+ *,
79
+ request: Optional[Dict[str, Any]] = None,
80
+ user: Optional[Dict[str, Any]] = None,
81
+ tags: Optional[Dict[str, str]] = None,
82
+ ) -> None:
83
+ """Per-event context capture. Use from middleware; do NOT use set_user/
84
+ set_tag for per-request data — they race under concurrency."""
85
+ if _client is not None:
86
+ _client.capture_exception_with_context(
87
+ exc, request=request, user=user, tags=tags
88
+ )
89
+
90
+
75
91
  def capture_message(msg: str, level: str = "info") -> None:
76
92
  if _client is not None:
77
93
  _client.capture_message(msg, level)
@@ -40,15 +40,21 @@ class Client:
40
40
  self.send_default_pii = send_default_pii
41
41
  self.transport = Transport(self.endpoint, dsn)
42
42
 
43
- def _prepare(self, exc: BaseException) -> Optional[Dict[str, Any]]:
43
+ def _prepare(
44
+ self,
45
+ exc: BaseException,
46
+ ctx: Optional[Dict[str, Any]] = None,
47
+ ) -> Optional[Dict[str, Any]]:
44
48
  snap = scope.snapshot()
49
+ ctx = ctx or {}
45
50
  event = build_event(
46
51
  exc,
47
52
  release=self.release,
48
53
  environment=self.environment,
49
- user=snap["user"],
50
- tags=snap["tags"],
54
+ user=ctx.get("user") or snap["user"],
55
+ tags=ctx.get("tags") or snap["tags"],
51
56
  breadcrumbs=snap["breadcrumbs"],
57
+ request=ctx.get("request"),
52
58
  )
53
59
  # Default scrub BEFORE before_send.
54
60
  scrub_event(
@@ -77,6 +83,29 @@ class Client:
77
83
  # SWALLOW. The SDK must never throw into the host app.
78
84
  pass
79
85
 
86
+ def capture_exception_with_context(
87
+ self,
88
+ exc: BaseException,
89
+ *,
90
+ request: Optional[Dict[str, Any]] = None,
91
+ user: Optional[Dict[str, Any]] = None,
92
+ tags: Optional[Dict[str, str]] = None,
93
+ ) -> None:
94
+ """Capture with per-event context. Framework middleware uses this so
95
+ request data is attached to THIS event without touching global scope —
96
+ safe under concurrent requests."""
97
+ try:
98
+ if random.random() > self.sample_rate:
99
+ return
100
+ event = self._prepare(
101
+ exc, {"request": request, "user": user, "tags": tags}
102
+ )
103
+ if event is None:
104
+ return
105
+ self.transport.enqueue(event)
106
+ except Exception:
107
+ pass
108
+
80
109
  def capture_message(self, message: str, level: str = "info") -> None:
81
110
  try:
82
111
  event = self._prepare(Exception(message))
@@ -28,6 +28,7 @@ def build_event(
28
28
  tags: Optional[Dict[str, str]] = None,
29
29
  breadcrumbs: Optional[List[Dict[str, Any]]] = None,
30
30
  url: Optional[str] = None,
31
+ request: Optional[Dict[str, Any]] = None,
31
32
  ) -> Dict[str, Any]:
32
33
  event: Dict[str, Any] = {
33
34
  "event_id": str(_uuid.uuid4()),
@@ -48,7 +49,13 @@ def build_event(
48
49
  event["release"] = release
49
50
  if environment is not None:
50
51
  event["environment"] = environment
51
- if url is not None:
52
+ # `request` (full dict with method+url) takes precedence over the legacy
53
+ # `url` kwarg used by the browser SDK.
54
+ if request:
55
+ event["request"] = {
56
+ k: v for k, v in request.items() if k in ("method", "url") and v
57
+ }
58
+ elif url is not None:
52
59
  event["request"] = {"url": url}
53
60
  return event
54
61
 
@@ -0,0 +1,3 @@
1
+ """Optional framework integrations. Importing this package does NOT pull in
2
+ any framework-specific runtime dependency — each submodule only needs the
3
+ stdlib + tinymonpy itself."""
@@ -0,0 +1,73 @@
1
+ """ASGI middleware. Wraps any ASGI app (FastAPI, Starlette, Django ASGI).
2
+
3
+ Usage with FastAPI::
4
+
5
+ from fastapi import FastAPI
6
+ import tinymonpy
7
+ from tinymonpy.frameworks.asgi import TinymonASGI
8
+
9
+ tinymonpy.init(dsn=os.environ["TINYMON_DSN"])
10
+ app = FastAPI()
11
+ app.add_middleware(TinymonASGI)
12
+
13
+ Captures with per-event request context (method + URL from the scope) and
14
+ re-raises so the framework's exception handlers still run.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Awaitable, Callable, Iterable
19
+
20
+ import tinymonpy as _tm
21
+
22
+ Scope = dict
23
+ Receive = Callable[[], Awaitable[dict]]
24
+ Send = Callable[[dict], Awaitable[None]]
25
+
26
+
27
+ def _url_from_scope(scope: Scope) -> str:
28
+ scheme = scope.get("scheme", "http")
29
+ headers: Iterable[tuple] = scope.get("headers") or []
30
+ host = ""
31
+ for k, v in headers:
32
+ if k == b"host":
33
+ host = v.decode("latin-1", errors="replace")
34
+ break
35
+ if not host:
36
+ srv = scope.get("server") or ()
37
+ host = f"{srv[0]}:{srv[1]}" if len(srv) >= 2 else ""
38
+ path = scope.get("raw_path") or scope.get("path", "")
39
+ if isinstance(path, bytes):
40
+ path = path.decode("latin-1", errors="replace")
41
+ qs = scope.get("query_string") or b""
42
+ if isinstance(qs, bytes):
43
+ qs = qs.decode("latin-1", errors="replace")
44
+ full = f"{path}?{qs}" if qs else path
45
+ return f"{scheme}://{host}{full}" if host else full
46
+
47
+
48
+ class TinymonASGI:
49
+ """Wrap an ASGI app; capture exceptions, then re-raise."""
50
+
51
+ def __init__(self, app: Callable[[Scope, Receive, Send], Awaitable[None]]) -> None:
52
+ self.app = app
53
+
54
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
55
+ if scope.get("type") not in ("http", "websocket"):
56
+ await self.app(scope, receive, send)
57
+ return
58
+ try:
59
+ await self.app(scope, receive, send)
60
+ except BaseException as exc:
61
+ client = _tm._client # type: ignore[attr-defined]
62
+ if client is not None:
63
+ try:
64
+ client.capture_exception_with_context(
65
+ exc,
66
+ request={
67
+ "method": scope.get("method"),
68
+ "url": _url_from_scope(scope),
69
+ },
70
+ )
71
+ except Exception:
72
+ pass
73
+ raise
@@ -0,0 +1,57 @@
1
+ """WSGI middleware. Wraps any WSGI app (Flask, Django sync, plain WSGI).
2
+
3
+ Usage with Flask::
4
+
5
+ from flask import Flask
6
+ import tinymonpy
7
+ from tinymonpy.frameworks.wsgi import TinymonWSGI
8
+
9
+ tinymonpy.init(dsn=os.environ["TINYMON_DSN"])
10
+ app = Flask(__name__)
11
+ app.wsgi_app = TinymonWSGI(app.wsgi_app)
12
+
13
+ The middleware captures with per-event request context (method + URL from
14
+ environ) and RE-RAISES — your framework's normal error handling still fires.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Callable, Iterable
19
+
20
+ import tinymonpy as _tm
21
+
22
+
23
+ def _url_from_environ(environ: dict) -> str:
24
+ scheme = environ.get("wsgi.url_scheme", "http")
25
+ host = environ.get("HTTP_HOST") or environ.get("SERVER_NAME", "")
26
+ path = environ.get("RAW_URI") or (
27
+ environ.get("SCRIPT_NAME", "") + environ.get("PATH_INFO", "")
28
+ )
29
+ qs = environ.get("QUERY_STRING")
30
+ if qs:
31
+ path = f"{path}?{qs}"
32
+ return f"{scheme}://{host}{path}" if host else path
33
+
34
+
35
+ class TinymonWSGI:
36
+ """Wrap a WSGI callable; capture exceptions, then re-raise."""
37
+
38
+ def __init__(self, app: Callable[..., Iterable[bytes]]) -> None:
39
+ self.app = app
40
+
41
+ def __call__(self, environ: dict, start_response: Callable[..., Any]):
42
+ try:
43
+ return self.app(environ, start_response)
44
+ except BaseException as exc:
45
+ client = _tm._client # type: ignore[attr-defined]
46
+ if client is not None:
47
+ try:
48
+ client.capture_exception_with_context(
49
+ exc,
50
+ request={
51
+ "method": environ.get("REQUEST_METHOD"),
52
+ "url": _url_from_environ(environ),
53
+ },
54
+ )
55
+ except Exception:
56
+ pass
57
+ raise
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinymonpy
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Tiny error monitoring SDK for Python.
5
5
  Author: tinymon
6
6
  License: MIT
@@ -12,4 +12,7 @@ tinymonpy.egg-info/PKG-INFO
12
12
  tinymonpy.egg-info/SOURCES.txt
13
13
  tinymonpy.egg-info/dependency_links.txt
14
14
  tinymonpy.egg-info/requires.txt
15
- tinymonpy.egg-info/top_level.txt
15
+ tinymonpy.egg-info/top_level.txt
16
+ tinymonpy/frameworks/__init__.py
17
+ tinymonpy/frameworks/asgi.py
18
+ tinymonpy/frameworks/wsgi.py
File without changes
File without changes
File without changes