tinymonpy 0.1.0__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.
tinymonpy/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ """tinymon — tiny error monitoring SDK for Python.
2
+
3
+ The public API mirrors the TS SDK so customers can move between languages
4
+ with no surprises::
5
+
6
+ import tinymon
7
+ tinymon.init(dsn="tm_pub_xxx", environment="production", release="1.0.0")
8
+ try:
9
+ risky_thing()
10
+ except Exception as e:
11
+ tinymon.capture_exception(e)
12
+
13
+ Uncaught exceptions (main thread and workers) are captured automatically after
14
+ ``init``.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Callable, Dict, Optional
19
+
20
+ from .client import Client
21
+ from .event_builder import build_event as _build_event # public for contract tests
22
+ from .integrations import install_global_handlers
23
+ from .scope import scope
24
+ from .stacktrace import parse_traceback as _parse_traceback # public for contract tests
25
+
26
+ __all__ = [
27
+ "init",
28
+ "capture_exception",
29
+ "capture_message",
30
+ "set_user",
31
+ "set_tag",
32
+ "add_breadcrumb",
33
+ "_build_event",
34
+ "_parse_traceback",
35
+ ]
36
+
37
+ __version__ = "0.1.0"
38
+
39
+ _client: Optional[Client] = None
40
+
41
+
42
+ def init(
43
+ dsn: str,
44
+ *,
45
+ endpoint: Optional[str] = None,
46
+ environment: Optional[str] = None,
47
+ release: Optional[str] = None,
48
+ sample_rate: float = 1.0,
49
+ before_send: Optional[Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]] = None,
50
+ ) -> None:
51
+ global _client
52
+ _client = Client(
53
+ dsn,
54
+ endpoint=endpoint,
55
+ environment=environment,
56
+ release=release,
57
+ sample_rate=sample_rate,
58
+ before_send=before_send,
59
+ )
60
+ install_global_handlers(_client)
61
+
62
+
63
+ def capture_exception(exc: BaseException) -> None:
64
+ if _client is not None:
65
+ _client.capture_exception(exc)
66
+
67
+
68
+ def capture_message(msg: str, level: str = "info") -> None:
69
+ if _client is not None:
70
+ _client.capture_message(msg, level)
71
+
72
+
73
+ def set_user(user: Dict[str, Any]) -> None:
74
+ scope.set_user(user)
75
+
76
+
77
+ def set_tag(key: str, value: str) -> None:
78
+ scope.set_tag(key, value)
79
+
80
+
81
+ def add_breadcrumb(crumb: Dict[str, Any]) -> None:
82
+ scope.add_breadcrumb(crumb)
tinymonpy/client.py ADDED
@@ -0,0 +1,75 @@
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
@@ -0,0 +1,57 @@
1
+ """Pure construction of a wire-format event from a Python exception.
2
+
3
+ Mirrors `packages/browser/src/eventBuilder.ts`. The schema source of truth is
4
+ `spec/event.schema.json` — every field below maps to one defined there.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import time
10
+ import uuid as _uuid
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from .stacktrace import parse_traceback
14
+
15
+ SDK_NAME = "tinymon.python"
16
+ SDK_VERSION = "0.1.0"
17
+
18
+ # Substrings (case-insensitive) in tag keys that we redact before sending.
19
+ _SENSITIVE = re.compile(r"password|token|secret|auth|card|cvv|ssn", re.IGNORECASE)
20
+
21
+
22
+ def build_event(
23
+ exc: BaseException,
24
+ *,
25
+ release: Optional[str] = None,
26
+ environment: Optional[str] = None,
27
+ user: Optional[Dict[str, Any]] = None,
28
+ tags: Optional[Dict[str, str]] = None,
29
+ breadcrumbs: Optional[List[Dict[str, Any]]] = None,
30
+ url: Optional[str] = None,
31
+ ) -> Dict[str, Any]:
32
+ event: Dict[str, Any] = {
33
+ "event_id": str(_uuid.uuid4()),
34
+ "timestamp": time.time(),
35
+ "platform": "python",
36
+ "level": "error",
37
+ "sdk": {"name": SDK_NAME, "version": SDK_VERSION},
38
+ "exception": {
39
+ "type": type(exc).__name__,
40
+ "value": str(exc),
41
+ "stacktrace": {"frames": parse_traceback(exc.__traceback__)},
42
+ },
43
+ "breadcrumbs": list(breadcrumbs or []),
44
+ "user": dict(user or {}),
45
+ "tags": _scrub(tags or {}),
46
+ }
47
+ if release is not None:
48
+ event["release"] = release
49
+ if environment is not None:
50
+ event["environment"] = environment
51
+ if url is not None:
52
+ event["request"] = {"url": url}
53
+ return event
54
+
55
+
56
+ def _scrub(tags: Dict[str, str]) -> Dict[str, str]:
57
+ return {k: ("[redacted]" if _SENSITIVE.search(k) else v) for k, v in tags.items()}
@@ -0,0 +1,38 @@
1
+ """Auto-capture hooks: uncaught exceptions on main and worker threads."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ import threading
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from .client import Client
10
+
11
+
12
+ def install_global_handlers(client: "Client") -> None:
13
+ # Main-thread uncaught exceptions.
14
+ orig_excepthook = sys.excepthook
15
+
16
+ def _hook(exc_type, exc_value, exc_traceback):
17
+ try:
18
+ if exc_value is not None:
19
+ client.capture_exception(exc_value)
20
+ except Exception:
21
+ pass
22
+ orig_excepthook(exc_type, exc_value, exc_traceback)
23
+
24
+ sys.excepthook = _hook
25
+
26
+ # Worker-thread uncaught exceptions (Python 3.8+).
27
+ if hasattr(threading, "excepthook"):
28
+ orig_thread_hook = threading.excepthook
29
+
30
+ def _thread_hook(args):
31
+ try:
32
+ if args.exc_value is not None:
33
+ client.capture_exception(args.exc_value)
34
+ except Exception:
35
+ pass
36
+ orig_thread_hook(args)
37
+
38
+ threading.excepthook = _thread_hook
tinymonpy/scope.py ADDED
@@ -0,0 +1,40 @@
1
+ """Process-wide user/tags/breadcrumbs context that rides along with events."""
2
+ from __future__ import annotations
3
+
4
+ import threading
5
+ from typing import Any, Dict, List
6
+
7
+ _MAX_BREADCRUMBS = 50
8
+
9
+
10
+ class Scope:
11
+ def __init__(self) -> None:
12
+ self._lock = threading.Lock()
13
+ self.user: Dict[str, Any] = {}
14
+ self.tags: Dict[str, str] = {}
15
+ self.breadcrumbs: List[Dict[str, Any]] = []
16
+
17
+ def set_user(self, user: Dict[str, Any]) -> None:
18
+ with self._lock:
19
+ self.user = dict(user)
20
+
21
+ def set_tag(self, key: str, value: str) -> None:
22
+ with self._lock:
23
+ self.tags[key] = value
24
+
25
+ def add_breadcrumb(self, crumb: Dict[str, Any]) -> None:
26
+ with self._lock:
27
+ self.breadcrumbs.append(crumb)
28
+ if len(self.breadcrumbs) > _MAX_BREADCRUMBS:
29
+ self.breadcrumbs.pop(0)
30
+
31
+ def snapshot(self) -> Dict[str, Any]:
32
+ with self._lock:
33
+ return {
34
+ "user": dict(self.user),
35
+ "tags": dict(self.tags),
36
+ "breadcrumbs": list(self.breadcrumbs),
37
+ }
38
+
39
+
40
+ scope = Scope()
@@ -0,0 +1,46 @@
1
+ """Convert a Python traceback into wire-format stack frames.
2
+
3
+ Frames are returned deepest-LAST, matching `spec/event.schema.json`. The deepest
4
+ frame is where the exception was raised; ``extract_tb`` already produces this
5
+ order so no reversal is needed.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import traceback as _tb
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ # Third-party install path (site-packages / dist-packages) and the stdlib
14
+ # directory are used to flag frames as NOT in_app — same intent as the JS SDK's
15
+ # "not in node_modules" check.
16
+ _STDLIB_PATH = os.path.dirname(os.__file__)
17
+ _THIRD_PARTY_MARKERS = ("site-packages", "dist-packages")
18
+
19
+
20
+ def parse_traceback(exc_traceback: Optional[Any]) -> List[Dict[str, Any]]:
21
+ if exc_traceback is None:
22
+ return []
23
+ frames: List[Dict[str, Any]] = []
24
+ for fs in _tb.extract_tb(exc_traceback):
25
+ filename = fs.filename or ""
26
+ frames.append(
27
+ {
28
+ "filename": filename,
29
+ "function": fs.name or "<anonymous>",
30
+ "lineno": fs.lineno or 0,
31
+ # Python <3.11 doesn't track columns; SDK sends 0.
32
+ "colno": 0,
33
+ "in_app": _is_in_app(filename),
34
+ }
35
+ )
36
+ return frames
37
+
38
+
39
+ def _is_in_app(filename: str) -> bool:
40
+ if not filename or filename.startswith("<"):
41
+ return False
42
+ if any(marker in filename for marker in _THIRD_PARTY_MARKERS):
43
+ return False
44
+ if _STDLIB_PATH and filename.startswith(_STDLIB_PATH):
45
+ return False
46
+ return True
tinymonpy/transport.py ADDED
@@ -0,0 +1,82 @@
1
+ """Batched HTTP transport. stdlib-only — no `requests` dependency.
2
+
3
+ Runs a daemon thread that flushes every 5 seconds. An ``atexit`` hook drains
4
+ the queue on shutdown so errors during teardown don't get lost.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import atexit
9
+ import json
10
+ import threading
11
+ from collections import deque
12
+ from typing import Any, Dict, Deque
13
+ from urllib import error, request
14
+
15
+ _FLUSH_INTERVAL = 5.0 # seconds
16
+ _MAX_BATCH = 10
17
+ _MAX_QUEUE = 30
18
+ _REQUEST_TIMEOUT = 5.0
19
+
20
+
21
+ class Transport:
22
+ def __init__(self, endpoint: str, dsn: str) -> None:
23
+ self.endpoint = endpoint
24
+ self.dsn = dsn
25
+ self._queue: Deque[Dict[str, Any]] = deque(maxlen=_MAX_QUEUE)
26
+ self._lock = threading.Lock()
27
+ self._stop = threading.Event()
28
+ self._thread = threading.Thread(
29
+ target=self._run, daemon=True, name="tinymon-transport"
30
+ )
31
+ self._thread.start()
32
+ atexit.register(self._on_exit)
33
+
34
+ def enqueue(self, event: Dict[str, Any]) -> None:
35
+ with self._lock:
36
+ self._queue.append(event)
37
+ should_flush = len(self._queue) >= _MAX_BATCH
38
+ if should_flush:
39
+ self.flush()
40
+
41
+ def flush(self) -> None:
42
+ with self._lock:
43
+ batch = []
44
+ while self._queue and len(batch) < _MAX_BATCH:
45
+ batch.append(self._queue.popleft())
46
+ for event in batch:
47
+ self._send(event)
48
+
49
+ def _send(self, event: Dict[str, Any]) -> None:
50
+ body = json.dumps(event).encode("utf-8")
51
+ req = request.Request(
52
+ self.endpoint,
53
+ data=body,
54
+ headers={
55
+ "Content-Type": "application/json",
56
+ "X-Tinymon-Key": self.dsn,
57
+ },
58
+ method="POST",
59
+ )
60
+ try:
61
+ with request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp:
62
+ resp.read()
63
+ except (error.URLError, OSError):
64
+ # Network failed — re-enqueue if there's room. Drops oldest on overflow.
65
+ with self._lock:
66
+ if len(self._queue) < _MAX_QUEUE:
67
+ self._queue.appendleft(event)
68
+
69
+ def _run(self) -> None:
70
+ while not self._stop.is_set():
71
+ self._stop.wait(_FLUSH_INTERVAL)
72
+ try:
73
+ self.flush()
74
+ except Exception:
75
+ pass
76
+
77
+ def _on_exit(self) -> None:
78
+ self._stop.set()
79
+ try:
80
+ self.flush()
81
+ except Exception:
82
+ pass
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: tinymonpy
3
+ Version: 0.1.0
4
+ Summary: Tiny error monitoring SDK for Python.
5
+ Author: tinymon
6
+ License: MIT
7
+ Project-URL: Homepage, https://tinymon.dev
8
+ Project-URL: Documentation, https://tinymon.dev/docs/python.html
9
+ Keywords: error,monitoring,tracking,tinymon
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Software Development :: Bug Tracking
16
+ Requires-Python: >=3.8
17
+ Provides-Extra: test
18
+ Requires-Dist: jsonschema>=4; extra == "test"
@@ -0,0 +1,11 @@
1
+ tinymonpy/__init__.py,sha256=8W3HvQckqixKfj9BFURT1u50oSdkfJTAN6cK_k7An80,2030
2
+ tinymonpy/client.py,sha256=2CWC3pH5Zk841VzIQsoT4C_XKD4Wo5nnNaJ6kCvihqM,2476
3
+ tinymonpy/event_builder.py,sha256=lwnmweYZlbgAMXvTwVKEkCGu15sT8vBlnZD4EjPyVgU,1801
4
+ tinymonpy/integrations.py,sha256=Ls0Kdrq6c7y7SXgK4k9n-bU-JOqq0e2NPYQy7RJjoOc,1074
5
+ tinymonpy/scope.py,sha256=1fTB-FMAM8diOQQsPQdvxZ5cNIpSt1KaY3NuIpux7Ao,1119
6
+ tinymonpy/stacktrace.py,sha256=SYull3hR84OaKvok4dklqGZIdPvjHQnw6hc7pOiBT4o,1558
7
+ tinymonpy/transport.py,sha256=FgKAv9sx8xMiIsdfZEKw-sCyoKXCmZSk4T04_MrkKZ4,2495
8
+ tinymonpy-0.1.0.dist-info/METADATA,sha256=QpBbFJP8LjyFhG4b5gn-8L9lATNAcMxwFMBm8LxsBRc,672
9
+ tinymonpy-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ tinymonpy-0.1.0.dist-info/top_level.txt,sha256=R_dMJyZ6QRuNru8nfOX-EkqNMtChIRV3cHqpYHltOOQ,10
11
+ tinymonpy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ tinymonpy