tinymonpy 0.1.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.1.0/PKG-INFO +18 -0
- tinymonpy-0.1.0/pyproject.toml +32 -0
- tinymonpy-0.1.0/setup.cfg +4 -0
- tinymonpy-0.1.0/tests/test_contract.py +87 -0
- tinymonpy-0.1.0/tinymonpy/__init__.py +82 -0
- tinymonpy-0.1.0/tinymonpy/client.py +75 -0
- tinymonpy-0.1.0/tinymonpy/event_builder.py +57 -0
- tinymonpy-0.1.0/tinymonpy/integrations.py +38 -0
- tinymonpy-0.1.0/tinymonpy/scope.py +40 -0
- tinymonpy-0.1.0/tinymonpy/stacktrace.py +46 -0
- tinymonpy-0.1.0/tinymonpy/transport.py +82 -0
- tinymonpy-0.1.0/tinymonpy.egg-info/PKG-INFO +18 -0
- tinymonpy-0.1.0/tinymonpy.egg-info/SOURCES.txt +14 -0
- tinymonpy-0.1.0/tinymonpy.egg-info/dependency_links.txt +1 -0
- tinymonpy-0.1.0/tinymonpy.egg-info/requires.txt +3 -0
- tinymonpy-0.1.0/tinymonpy.egg-info/top_level.txt +1 -0
tinymonpy-0.1.0/PKG-INFO
ADDED
|
@@ -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,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tinymonpy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Tiny error monitoring SDK for Python."
|
|
9
|
+
requires-python = ">=3.8"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "tinymon" }]
|
|
12
|
+
keywords = ["error", "monitoring", "tracking", "tinymon"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Topic :: Software Development :: Bug Tracking",
|
|
20
|
+
]
|
|
21
|
+
# Zero runtime dependencies — uses stdlib only (urllib, threading, atexit).
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://tinymon.dev"
|
|
25
|
+
Documentation = "https://tinymon.dev/docs/python.html"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
test = ["jsonschema>=4"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["."]
|
|
32
|
+
include = ["tinymonpy*"]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Contract test: the Python SDK must emit events that validate against
|
|
2
|
+
``spec/event.schema.json``. Run from the repo root::
|
|
3
|
+
|
|
4
|
+
pip install jsonschema
|
|
5
|
+
python -m unittest packages/python/tests/test_contract.py
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
import unittest
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
# Import from the in-repo source.
|
|
15
|
+
HERE = Path(__file__).resolve()
|
|
16
|
+
sys.path.insert(0, str(HERE.parents[1]))
|
|
17
|
+
|
|
18
|
+
from tinymon.event_builder import build_event # noqa: E402
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from jsonschema import Draft202012Validator
|
|
22
|
+
except ImportError: # pragma: no cover
|
|
23
|
+
print(
|
|
24
|
+
"ERROR: jsonschema not installed. Run: pip install jsonschema",
|
|
25
|
+
file=sys.stderr,
|
|
26
|
+
)
|
|
27
|
+
sys.exit(2)
|
|
28
|
+
|
|
29
|
+
REPO_ROOT = HERE.parents[3]
|
|
30
|
+
SPEC = REPO_ROOT / "spec"
|
|
31
|
+
SCHEMA = json.loads((SPEC / "event.schema.json").read_text())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestContract(unittest.TestCase):
|
|
35
|
+
def setUp(self) -> None:
|
|
36
|
+
self.validator = Draft202012Validator(SCHEMA)
|
|
37
|
+
|
|
38
|
+
def _validate(self, event):
|
|
39
|
+
errors = list(self.validator.iter_errors(event))
|
|
40
|
+
if errors:
|
|
41
|
+
self.fail(
|
|
42
|
+
"schema errors:\n " + "\n ".join(e.message for e in errors)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def test_canonical_wire_fixture_is_valid(self):
|
|
46
|
+
fx = json.loads(
|
|
47
|
+
(SPEC / "fixtures" / "wire" / "canonical_event.json").read_text()
|
|
48
|
+
)
|
|
49
|
+
self._validate(fx["event"])
|
|
50
|
+
|
|
51
|
+
def test_minimal_wire_fixture_is_valid(self):
|
|
52
|
+
fx = json.loads(
|
|
53
|
+
(SPEC / "fixtures" / "wire" / "minimal_event.json").read_text()
|
|
54
|
+
)
|
|
55
|
+
self._validate(fx["event"])
|
|
56
|
+
|
|
57
|
+
def test_sdk_output_validates_against_schema(self):
|
|
58
|
+
try:
|
|
59
|
+
raise ValueError("boom")
|
|
60
|
+
except ValueError as e:
|
|
61
|
+
event = build_event(
|
|
62
|
+
e,
|
|
63
|
+
release="1.0.0",
|
|
64
|
+
environment="test",
|
|
65
|
+
user={"id": "u1"},
|
|
66
|
+
tags={"feature": "checkout", "password": "shh"},
|
|
67
|
+
)
|
|
68
|
+
self._validate(event)
|
|
69
|
+
|
|
70
|
+
self.assertEqual(event["platform"], "python")
|
|
71
|
+
self.assertEqual(event["sdk"]["name"], "tinymon.python")
|
|
72
|
+
self.assertEqual(event["exception"]["type"], "ValueError")
|
|
73
|
+
self.assertEqual(event["exception"]["value"], "boom")
|
|
74
|
+
self.assertEqual(event["tags"]["password"], "[redacted]")
|
|
75
|
+
|
|
76
|
+
frames = event["exception"]["stacktrace"]["frames"]
|
|
77
|
+
self.assertGreaterEqual(len(frames), 1, "expected at least one frame")
|
|
78
|
+
deepest = frames[-1]
|
|
79
|
+
self.assertTrue(
|
|
80
|
+
deepest["in_app"],
|
|
81
|
+
f"expected the deepest frame to be in_app, got {deepest}",
|
|
82
|
+
)
|
|
83
|
+
self.assertEqual(deepest["function"], "test_sdk_output_validates_against_schema")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
unittest.main()
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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,14 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
tests/test_contract.py
|
|
3
|
+
tinymonpy/__init__.py
|
|
4
|
+
tinymonpy/client.py
|
|
5
|
+
tinymonpy/event_builder.py
|
|
6
|
+
tinymonpy/integrations.py
|
|
7
|
+
tinymonpy/scope.py
|
|
8
|
+
tinymonpy/stacktrace.py
|
|
9
|
+
tinymonpy/transport.py
|
|
10
|
+
tinymonpy.egg-info/PKG-INFO
|
|
11
|
+
tinymonpy.egg-info/SOURCES.txt
|
|
12
|
+
tinymonpy.egg-info/dependency_links.txt
|
|
13
|
+
tinymonpy.egg-info/requires.txt
|
|
14
|
+
tinymonpy.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tinymonpy
|