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.
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/PKG-INFO +1 -1
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/pyproject.toml +1 -1
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/tinymonpy/__init__.py +17 -1
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/tinymonpy/client.py +32 -3
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/tinymonpy/event_builder.py +8 -1
- tinymonpy-0.3.0/tinymonpy/frameworks/__init__.py +3 -0
- tinymonpy-0.3.0/tinymonpy/frameworks/asgi.py +73 -0
- tinymonpy-0.3.0/tinymonpy/frameworks/wsgi.py +57 -0
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/tinymonpy.egg-info/PKG-INFO +1 -1
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/tinymonpy.egg-info/SOURCES.txt +4 -1
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/setup.cfg +0 -0
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/tests/test_contract.py +0 -0
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/tinymonpy/integrations.py +0 -0
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/tinymonpy/scope.py +0 -0
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/tinymonpy/scrub.py +0 -0
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/tinymonpy/stacktrace.py +0 -0
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/tinymonpy/transport.py +0 -0
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/tinymonpy.egg-info/dependency_links.txt +0 -0
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/tinymonpy.egg-info/requires.txt +0 -0
- {tinymonpy-0.2.0 → tinymonpy-0.3.0}/tinymonpy.egg-info/top_level.txt +0 -0
|
@@ -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.
|
|
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(
|
|
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
|
-
|
|
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,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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|