tinymonpy 0.1.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.1.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.1.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.1.0"
38
+ __version__ = "0.3.0"
38
39
 
39
40
  _client: Optional[Client] = None
40
41
 
@@ -47,6 +48,10 @@ def init(
47
48
  release: Optional[str] = None,
48
49
  sample_rate: float = 1.0,
49
50
  before_send: Optional[Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]] = None,
51
+ # Privacy controls. See https://tinymon.dev/docs/privacy.html
52
+ scrub_url_query: bool = True,
53
+ scrub_patterns: Optional[list] = None,
54
+ send_default_pii: bool = False,
50
55
  ) -> None:
51
56
  global _client
52
57
  _client = Client(
@@ -56,6 +61,9 @@ def init(
56
61
  release=release,
57
62
  sample_rate=sample_rate,
58
63
  before_send=before_send,
64
+ scrub_url_query=scrub_url_query,
65
+ scrub_patterns=scrub_patterns,
66
+ send_default_pii=send_default_pii,
59
67
  )
60
68
  install_global_handlers(_client)
61
69
 
@@ -65,6 +73,21 @@ def capture_exception(exc: BaseException) -> None:
65
73
  _client.capture_exception(exc)
66
74
 
67
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
+
68
91
  def capture_message(msg: str, level: str = "info") -> None:
69
92
  if _client is not None:
70
93
  _client.capture_message(msg, level)
@@ -0,0 +1,118 @@
1
+ """The Client orchestrates capture: build event, scrub, run before_send, enqueue."""
2
+ from __future__ import annotations
3
+
4
+ import random
5
+ from typing import Any, Callable, Dict, List, Optional, Pattern
6
+
7
+ from .event_builder import build_event
8
+ from .scope import scope
9
+ from .scrub import scrub_event
10
+ from .transport import Transport
11
+
12
+ _DEFAULT_ENDPOINT = "https://console.tinymon.dev/api/ingest"
13
+
14
+ BeforeSend = Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]
15
+
16
+
17
+ class Client:
18
+ def __init__(
19
+ self,
20
+ dsn: str,
21
+ *,
22
+ endpoint: Optional[str] = None,
23
+ environment: Optional[str] = None,
24
+ release: Optional[str] = None,
25
+ sample_rate: float = 1.0,
26
+ before_send: Optional[BeforeSend] = None,
27
+ # Privacy controls — defaults are privacy-preserving.
28
+ scrub_url_query: bool = True,
29
+ scrub_patterns: Optional[List[Pattern[str]]] = None,
30
+ send_default_pii: bool = False,
31
+ ) -> None:
32
+ self.dsn = dsn
33
+ self.endpoint = endpoint or _DEFAULT_ENDPOINT
34
+ self.environment = environment
35
+ self.release = release
36
+ self.sample_rate = sample_rate
37
+ self.before_send = before_send
38
+ self.scrub_url_query = scrub_url_query
39
+ self.scrub_patterns = scrub_patterns
40
+ self.send_default_pii = send_default_pii
41
+ self.transport = Transport(self.endpoint, dsn)
42
+
43
+ def _prepare(
44
+ self,
45
+ exc: BaseException,
46
+ ctx: Optional[Dict[str, Any]] = None,
47
+ ) -> Optional[Dict[str, Any]]:
48
+ snap = scope.snapshot()
49
+ ctx = ctx or {}
50
+ event = build_event(
51
+ exc,
52
+ release=self.release,
53
+ environment=self.environment,
54
+ user=ctx.get("user") or snap["user"],
55
+ tags=ctx.get("tags") or snap["tags"],
56
+ breadcrumbs=snap["breadcrumbs"],
57
+ request=ctx.get("request"),
58
+ )
59
+ # Default scrub BEFORE before_send.
60
+ scrub_event(
61
+ event,
62
+ scrub_url_query=self.scrub_url_query,
63
+ scrub_patterns=self.scrub_patterns,
64
+ )
65
+ # PII gate on the wire — server only attaches IP if true.
66
+ event["send_default_pii"] = bool(self.send_default_pii)
67
+ if self.before_send is not None:
68
+ result = self.before_send(event)
69
+ if result is None:
70
+ return None
71
+ event = result
72
+ return event
73
+
74
+ def capture_exception(self, exc: BaseException) -> None:
75
+ try:
76
+ if random.random() > self.sample_rate:
77
+ return
78
+ event = self._prepare(exc)
79
+ if event is None:
80
+ return
81
+ self.transport.enqueue(event)
82
+ except Exception:
83
+ # SWALLOW. The SDK must never throw into the host app.
84
+ pass
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
+
109
+ def capture_message(self, message: str, level: str = "info") -> None:
110
+ try:
111
+ event = self._prepare(Exception(message))
112
+ if event is None:
113
+ return
114
+ event["level"] = level
115
+ event["exception"]["type"] = "Message"
116
+ self.transport.enqueue(event)
117
+ except Exception:
118
+ pass
@@ -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
@@ -0,0 +1,67 @@
1
+ """Default PII scrub. Identical semantics to the JS SDK and the server.
2
+
3
+ Runs BEFORE before_send so user hooks can both relax and tighten the defaults.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from typing import Any, Dict, List, Optional, Pattern
9
+
10
+ SENSITIVE_TAG_KEY: Pattern[str] = re.compile(
11
+ r"password|token|secret|auth|card|cvv|ssn", re.IGNORECASE
12
+ )
13
+
14
+ _EMAIL = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
15
+ _CARD = re.compile(r"\b(?:\d[ -]?){12,15}\d\b")
16
+ _BEARER = re.compile(r"\b[Bb]earer\s+[A-Za-z0-9._\-]+")
17
+ _AUTH_HDR = re.compile(r"\bAuthorization\s*:\s*\S+")
18
+
19
+ DEFAULT_PATTERNS: List[Pattern[str]] = [_EMAIL, _CARD, _BEARER, _AUTH_HDR]
20
+
21
+
22
+ def _scrub_string(s: str, patterns: List[Pattern[str]]) -> str:
23
+ out = s
24
+ for re_ in patterns:
25
+ out = re_.sub("[redacted]", out)
26
+ return out
27
+
28
+
29
+ def _strip_url_query(url: Optional[str]) -> Optional[str]:
30
+ if not url:
31
+ return url
32
+ for i, ch in enumerate(url):
33
+ if ch in "?#":
34
+ return url[:i]
35
+ return url
36
+
37
+
38
+ def scrub_event(
39
+ event: Dict[str, Any],
40
+ *,
41
+ scrub_url_query: bool = True,
42
+ scrub_patterns: Optional[List[Pattern[str]]] = None,
43
+ ) -> Dict[str, Any]:
44
+ """Mutate the event dict in place. Returns it for chaining."""
45
+ patterns = list(DEFAULT_PATTERNS) + list(scrub_patterns or [])
46
+
47
+ req = event.get("request") or {}
48
+ if scrub_url_query and req.get("url"):
49
+ req["url"] = _strip_url_query(req.get("url"))
50
+ event["request"] = req
51
+
52
+ exc = event.get("exception") or {}
53
+ if exc.get("value"):
54
+ exc["value"] = _scrub_string(exc["value"], patterns)
55
+
56
+ for b in event.get("breadcrumbs") or []:
57
+ if b.get("message"):
58
+ b["message"] = _scrub_string(b["message"], patterns)
59
+
60
+ tags = event.get("tags")
61
+ if tags:
62
+ cleaned: Dict[str, str] = {}
63
+ for k, v in tags.items():
64
+ cleaned[k] = "[redacted]" if SENSITIVE_TAG_KEY.search(k) else v
65
+ event["tags"] = cleaned
66
+
67
+ return event
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinymonpy
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Tiny error monitoring SDK for Python.
5
5
  Author: tinymon
6
6
  License: MIT
@@ -5,10 +5,14 @@ tinymonpy/client.py
5
5
  tinymonpy/event_builder.py
6
6
  tinymonpy/integrations.py
7
7
  tinymonpy/scope.py
8
+ tinymonpy/scrub.py
8
9
  tinymonpy/stacktrace.py
9
10
  tinymonpy/transport.py
10
11
  tinymonpy.egg-info/PKG-INFO
11
12
  tinymonpy.egg-info/SOURCES.txt
12
13
  tinymonpy.egg-info/dependency_links.txt
13
14
  tinymonpy.egg-info/requires.txt
14
- 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
@@ -1,75 +0,0 @@
1
- """The Client orchestrates capture: build event, run beforeSend, enqueue."""
2
- from __future__ import annotations
3
-
4
- import random
5
- from typing import Any, Callable, Dict, Optional
6
-
7
- from .event_builder import build_event
8
- from .scope import scope
9
- from .transport import Transport
10
-
11
- _DEFAULT_ENDPOINT = "https://console.tinymon.dev/api/ingest"
12
-
13
- BeforeSend = Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]
14
-
15
-
16
- class Client:
17
- def __init__(
18
- self,
19
- dsn: str,
20
- *,
21
- endpoint: Optional[str] = None,
22
- environment: Optional[str] = None,
23
- release: Optional[str] = None,
24
- sample_rate: float = 1.0,
25
- before_send: Optional[BeforeSend] = None,
26
- ) -> None:
27
- self.dsn = dsn
28
- self.endpoint = endpoint or _DEFAULT_ENDPOINT
29
- self.environment = environment
30
- self.release = release
31
- self.sample_rate = sample_rate
32
- self.before_send = before_send
33
- self.transport = Transport(self.endpoint, dsn)
34
-
35
- def capture_exception(self, exc: BaseException) -> None:
36
- try:
37
- if random.random() > self.sample_rate:
38
- return
39
- snap = scope.snapshot()
40
- event = build_event(
41
- exc,
42
- release=self.release,
43
- environment=self.environment,
44
- user=snap["user"],
45
- tags=snap["tags"],
46
- breadcrumbs=snap["breadcrumbs"],
47
- )
48
- if self.before_send is not None:
49
- result = self.before_send(event)
50
- if result is None:
51
- return
52
- event = result
53
- self.transport.enqueue(event)
54
- except Exception:
55
- # SWALLOW. The SDK must never throw into the host app.
56
- pass
57
-
58
- def capture_message(self, message: str, level: str = "info") -> None:
59
- try:
60
- # Synthesize an exception without a stack — value carries the message.
61
- synthetic = Exception(message)
62
- snap = scope.snapshot()
63
- event = build_event(
64
- synthetic,
65
- release=self.release,
66
- environment=self.environment,
67
- user=snap["user"],
68
- tags=snap["tags"],
69
- breadcrumbs=snap["breadcrumbs"],
70
- )
71
- event["level"] = level
72
- event["exception"]["type"] = "Message"
73
- self.transport.enqueue(event)
74
- except Exception:
75
- pass
File without changes
File without changes