tiden-telemetry 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.
- tiden_telemetry-0.1.0/.github/workflows/ci.yml +36 -0
- tiden_telemetry-0.1.0/.github/workflows/publish.yml +31 -0
- tiden_telemetry-0.1.0/.gitignore +11 -0
- tiden_telemetry-0.1.0/LICENSE +21 -0
- tiden_telemetry-0.1.0/PKG-INFO +141 -0
- tiden_telemetry-0.1.0/README.md +112 -0
- tiden_telemetry-0.1.0/pyproject.toml +51 -0
- tiden_telemetry-0.1.0/src/tiden_telemetry/__init__.py +102 -0
- tiden_telemetry-0.1.0/src/tiden_telemetry/_client.py +145 -0
- tiden_telemetry-0.1.0/src/tiden_telemetry/_dsn.py +40 -0
- tiden_telemetry-0.1.0/src/tiden_telemetry/_envelope.py +37 -0
- tiden_telemetry-0.1.0/src/tiden_telemetry/_event.py +103 -0
- tiden_telemetry-0.1.0/src/tiden_telemetry/_scrub.py +53 -0
- tiden_telemetry-0.1.0/src/tiden_telemetry/_transport.py +57 -0
- tiden_telemetry-0.1.0/src/tiden_telemetry/logging.py +45 -0
- tiden_telemetry-0.1.0/src/tiden_telemetry/py.typed +0 -0
- tiden_telemetry-0.1.0/src/tiden_telemetry/wsgi.py +53 -0
- tiden_telemetry-0.1.0/tests/test_client.py +149 -0
- tiden_telemetry-0.1.0/tests/test_contract.py +78 -0
- tiden_telemetry-0.1.0/tests/test_dsn.py +31 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
python: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
- uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python }}
|
|
23
|
+
- run: pip install -e ".[dev]"
|
|
24
|
+
- run: pytest -q
|
|
25
|
+
|
|
26
|
+
lint:
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
- uses: actions/setup-python@v5
|
|
31
|
+
with:
|
|
32
|
+
python-version: '3.12'
|
|
33
|
+
- run: pip install -e ".[dev]"
|
|
34
|
+
- run: ruff check .
|
|
35
|
+
- run: ruff format --check .
|
|
36
|
+
- run: mypy
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
# Publishes to PyPI on a version tag via OIDC trusted publishing — no tokens.
|
|
4
|
+
# pypa/gh-action-pypi-publish attaches PEP 740 provenance attestations by default.
|
|
5
|
+
#
|
|
6
|
+
# One-time setup on pypi.org: add a "pending publisher" for project
|
|
7
|
+
# `tiden-telemetry` → owner `qase-tms`, repo `tiden-telemetry-python`,
|
|
8
|
+
# workflow `publish.yml` (leave environment empty). The first tag then creates
|
|
9
|
+
# the project automatically.
|
|
10
|
+
|
|
11
|
+
on:
|
|
12
|
+
push:
|
|
13
|
+
tags: ['v*']
|
|
14
|
+
workflow_dispatch:
|
|
15
|
+
|
|
16
|
+
permissions:
|
|
17
|
+
contents: read
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
publish:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
permissions:
|
|
23
|
+
id-token: write # OIDC -> PyPI trusted publishing (+ attestations)
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
- uses: actions/setup-python@v5
|
|
27
|
+
with:
|
|
28
|
+
python-version: '3.12'
|
|
29
|
+
- run: pip install build
|
|
30
|
+
- run: python -m build
|
|
31
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tiden
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tiden-telemetry
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Error-tracking SDK for Python — captures errors and reports them to a Tiden project.
|
|
5
|
+
Project-URL: Homepage, https://tiden.ai
|
|
6
|
+
Project-URL: Repository, https://github.com/qase-tms/tiden-telemetry-python
|
|
7
|
+
Project-URL: Issues, https://github.com/qase-tms/tiden-telemetry-python/issues
|
|
8
|
+
Author: Tiden
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: error-tracking,exceptions,monitoring,observability,telemetry,tiden
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Bug Tracking
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# tiden-telemetry (Python)
|
|
31
|
+
|
|
32
|
+
[](https://github.com/qase-tms/tiden-telemetry-python/actions/workflows/ci.yml)
|
|
33
|
+
[](https://pypi.org/project/tiden-telemetry/)
|
|
34
|
+
[](https://pypi.org/project/tiden-telemetry/)
|
|
35
|
+
|
|
36
|
+
Error-tracking SDK for **Python** apps. Captures errors and uncaught exceptions
|
|
37
|
+
and reports them to your [Tiden](https://tiden.ai) project, with WSGI middleware
|
|
38
|
+
and a `logging` handler. **Zero dependencies** (standard library only), typed.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install tiden-telemetry
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import tiden_telemetry as tiden
|
|
50
|
+
|
|
51
|
+
tiden.init(
|
|
52
|
+
dsn="http://<publicKey>@<host:ingestPort>/<projectId>",
|
|
53
|
+
release="my-app@1.2.3",
|
|
54
|
+
environment="production",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
do_work()
|
|
59
|
+
except Exception:
|
|
60
|
+
tiden.capture_exception() # inside an except block, no argument needed
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`init` installs `sys.excepthook` / `threading.excepthook`, so uncaught
|
|
64
|
+
exceptions are reported automatically.
|
|
65
|
+
|
|
66
|
+
## Capturing
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
tiden.capture_exception(err)
|
|
70
|
+
tiden.capture_message("checkout completed", "info")
|
|
71
|
+
|
|
72
|
+
tiden.set_tag("plan", "pro")
|
|
73
|
+
tiden.set_user({"id": "u_123"})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## WSGI middleware
|
|
77
|
+
|
|
78
|
+
Captures unhandled exceptions in your app, then re-raises (so the server's own
|
|
79
|
+
error handling still runs):
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from tiden_telemetry.wsgi import TidenWsgiMiddleware
|
|
83
|
+
|
|
84
|
+
application = TidenWsgiMiddleware(application)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The captured event includes request URL, method, and headers (sensitive headers
|
|
88
|
+
are scrubbed unless `send_default_pii` is set).
|
|
89
|
+
|
|
90
|
+
## logging handler
|
|
91
|
+
|
|
92
|
+
Route records (≥ `ERROR` by default) through Tiden — `logger.exception(...)`
|
|
93
|
+
becomes a captured exception:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import logging
|
|
97
|
+
from tiden_telemetry.logging import TidenLoggingHandler
|
|
98
|
+
|
|
99
|
+
logging.getLogger().addHandler(TidenLoggingHandler())
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Options
|
|
103
|
+
|
|
104
|
+
`tiden.init(...)` / `tiden.Client(...)`:
|
|
105
|
+
|
|
106
|
+
| Option | Type | Default | Description |
|
|
107
|
+
|---|---|---|---|
|
|
108
|
+
| `dsn` | `str` | — | **Required.** `http://<publicKey>@<host:ingestPort>/<projectId>`. |
|
|
109
|
+
| `release` | `str` | `None` | App version, e.g. `my-app@1.2.3`. |
|
|
110
|
+
| `environment` | `str` | `None` | e.g. `production`, `staging`. |
|
|
111
|
+
| `send_default_pii` | `bool` | `False` | Send likely-PII. Off by default — common PII is scrubbed. |
|
|
112
|
+
| `before_send` | `Callable[[dict], dict \| None]` | `None` | Inspect, mutate, or drop an event. Return `None` to drop. |
|
|
113
|
+
| `http_timeout` | `float` | `2.0` | Bounds each synchronous send (seconds). |
|
|
114
|
+
| `install_excepthook` | `bool` | `True` (`init`) | Install global excepthooks. |
|
|
115
|
+
|
|
116
|
+
Use `tiden.Client(...)` directly for multiple clients or to avoid global state;
|
|
117
|
+
pass it to `TidenWsgiMiddleware(app, client=...)` / `TidenLoggingHandler(client=...)`.
|
|
118
|
+
|
|
119
|
+
## How it works
|
|
120
|
+
|
|
121
|
+
- Parses the DSN to `/api/<projectId>/envelope/?tiden_key=…`.
|
|
122
|
+
- Normalizes exceptions (incl. the `__cause__` / `__context__` chain) into
|
|
123
|
+
`exception.values[]` with stack frames (`in_app` excludes the stdlib and
|
|
124
|
+
site-packages).
|
|
125
|
+
- Serializes the envelope and POSTs it with `Content-Type:
|
|
126
|
+
application/x-tiden-envelope` — **synchronous, never raises into the host**;
|
|
127
|
+
honors HTTP 429 + `Retry-After`.
|
|
128
|
+
- Scrubs likely-PII (auth headers, secret-ish keys) unless `send_default_pii`.
|
|
129
|
+
|
|
130
|
+
## Develop
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
pip install -e ".[dev]"
|
|
134
|
+
ruff check . && ruff format --check .
|
|
135
|
+
mypy
|
|
136
|
+
pytest -q
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
[MIT](LICENSE) © Tiden
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# tiden-telemetry (Python)
|
|
2
|
+
|
|
3
|
+
[](https://github.com/qase-tms/tiden-telemetry-python/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/tiden-telemetry/)
|
|
5
|
+
[](https://pypi.org/project/tiden-telemetry/)
|
|
6
|
+
|
|
7
|
+
Error-tracking SDK for **Python** apps. Captures errors and uncaught exceptions
|
|
8
|
+
and reports them to your [Tiden](https://tiden.ai) project, with WSGI middleware
|
|
9
|
+
and a `logging` handler. **Zero dependencies** (standard library only), typed.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install tiden-telemetry
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import tiden_telemetry as tiden
|
|
21
|
+
|
|
22
|
+
tiden.init(
|
|
23
|
+
dsn="http://<publicKey>@<host:ingestPort>/<projectId>",
|
|
24
|
+
release="my-app@1.2.3",
|
|
25
|
+
environment="production",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
do_work()
|
|
30
|
+
except Exception:
|
|
31
|
+
tiden.capture_exception() # inside an except block, no argument needed
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`init` installs `sys.excepthook` / `threading.excepthook`, so uncaught
|
|
35
|
+
exceptions are reported automatically.
|
|
36
|
+
|
|
37
|
+
## Capturing
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
tiden.capture_exception(err)
|
|
41
|
+
tiden.capture_message("checkout completed", "info")
|
|
42
|
+
|
|
43
|
+
tiden.set_tag("plan", "pro")
|
|
44
|
+
tiden.set_user({"id": "u_123"})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## WSGI middleware
|
|
48
|
+
|
|
49
|
+
Captures unhandled exceptions in your app, then re-raises (so the server's own
|
|
50
|
+
error handling still runs):
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from tiden_telemetry.wsgi import TidenWsgiMiddleware
|
|
54
|
+
|
|
55
|
+
application = TidenWsgiMiddleware(application)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The captured event includes request URL, method, and headers (sensitive headers
|
|
59
|
+
are scrubbed unless `send_default_pii` is set).
|
|
60
|
+
|
|
61
|
+
## logging handler
|
|
62
|
+
|
|
63
|
+
Route records (≥ `ERROR` by default) through Tiden — `logger.exception(...)`
|
|
64
|
+
becomes a captured exception:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import logging
|
|
68
|
+
from tiden_telemetry.logging import TidenLoggingHandler
|
|
69
|
+
|
|
70
|
+
logging.getLogger().addHandler(TidenLoggingHandler())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Options
|
|
74
|
+
|
|
75
|
+
`tiden.init(...)` / `tiden.Client(...)`:
|
|
76
|
+
|
|
77
|
+
| Option | Type | Default | Description |
|
|
78
|
+
|---|---|---|---|
|
|
79
|
+
| `dsn` | `str` | — | **Required.** `http://<publicKey>@<host:ingestPort>/<projectId>`. |
|
|
80
|
+
| `release` | `str` | `None` | App version, e.g. `my-app@1.2.3`. |
|
|
81
|
+
| `environment` | `str` | `None` | e.g. `production`, `staging`. |
|
|
82
|
+
| `send_default_pii` | `bool` | `False` | Send likely-PII. Off by default — common PII is scrubbed. |
|
|
83
|
+
| `before_send` | `Callable[[dict], dict \| None]` | `None` | Inspect, mutate, or drop an event. Return `None` to drop. |
|
|
84
|
+
| `http_timeout` | `float` | `2.0` | Bounds each synchronous send (seconds). |
|
|
85
|
+
| `install_excepthook` | `bool` | `True` (`init`) | Install global excepthooks. |
|
|
86
|
+
|
|
87
|
+
Use `tiden.Client(...)` directly for multiple clients or to avoid global state;
|
|
88
|
+
pass it to `TidenWsgiMiddleware(app, client=...)` / `TidenLoggingHandler(client=...)`.
|
|
89
|
+
|
|
90
|
+
## How it works
|
|
91
|
+
|
|
92
|
+
- Parses the DSN to `/api/<projectId>/envelope/?tiden_key=…`.
|
|
93
|
+
- Normalizes exceptions (incl. the `__cause__` / `__context__` chain) into
|
|
94
|
+
`exception.values[]` with stack frames (`in_app` excludes the stdlib and
|
|
95
|
+
site-packages).
|
|
96
|
+
- Serializes the envelope and POSTs it with `Content-Type:
|
|
97
|
+
application/x-tiden-envelope` — **synchronous, never raises into the host**;
|
|
98
|
+
honors HTTP 429 + `Retry-After`.
|
|
99
|
+
- Scrubs likely-PII (auth headers, secret-ish keys) unless `send_default_pii`.
|
|
100
|
+
|
|
101
|
+
## Develop
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install -e ".[dev]"
|
|
105
|
+
ruff check . && ruff format --check .
|
|
106
|
+
mypy
|
|
107
|
+
pytest -q
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
[MIT](LICENSE) © Tiden
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tiden-telemetry"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Error-tracking SDK for Python — captures errors and reports them to a Tiden project."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "Tiden" }]
|
|
14
|
+
keywords = ["error-tracking", "monitoring", "telemetry", "tiden", "exceptions", "observability"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Software Development :: Bug Tracking",
|
|
25
|
+
"Topic :: System :: Monitoring",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://tiden.ai"
|
|
32
|
+
Repository = "https://github.com/qase-tms/tiden-telemetry-python"
|
|
33
|
+
Issues = "https://github.com/qase-tms/tiden-telemetry-python/issues"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = ["pytest>=7", "ruff>=0.6", "mypy>=1.8"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/tiden_telemetry"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 100
|
|
43
|
+
target-version = "py39"
|
|
44
|
+
src = ["src", "tests"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
48
|
+
|
|
49
|
+
[tool.mypy]
|
|
50
|
+
strict = true
|
|
51
|
+
files = ["src"]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Error-tracking SDK for Python — reports errors and messages to a Tiden project.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import tiden_telemetry as tiden
|
|
6
|
+
|
|
7
|
+
tiden.init(
|
|
8
|
+
dsn="http://<publicKey>@<host:ingestPort>/<projectId>",
|
|
9
|
+
release="my-app@1.2.3",
|
|
10
|
+
environment="production",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
do_work()
|
|
15
|
+
except Exception:
|
|
16
|
+
tiden.capture_exception()
|
|
17
|
+
|
|
18
|
+
Uncaught exceptions are reported automatically once ``init`` is called.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from ._client import BeforeSend, Client
|
|
26
|
+
from ._dsn import Dsn, parse_dsn
|
|
27
|
+
from ._event import VERSION
|
|
28
|
+
from ._transport import Transport
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"VERSION",
|
|
32
|
+
"BeforeSend",
|
|
33
|
+
"Client",
|
|
34
|
+
"Dsn",
|
|
35
|
+
"Transport",
|
|
36
|
+
"capture_exception",
|
|
37
|
+
"capture_message",
|
|
38
|
+
"get_client",
|
|
39
|
+
"init",
|
|
40
|
+
"parse_dsn",
|
|
41
|
+
"set_tag",
|
|
42
|
+
"set_user",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
_client: Client | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def init(
|
|
49
|
+
dsn: str,
|
|
50
|
+
*,
|
|
51
|
+
release: str | None = None,
|
|
52
|
+
environment: str | None = None,
|
|
53
|
+
send_default_pii: bool = False,
|
|
54
|
+
before_send: BeforeSend | None = None,
|
|
55
|
+
transport: Transport | None = None,
|
|
56
|
+
http_timeout: float = 2.0,
|
|
57
|
+
install_excepthook: bool = True,
|
|
58
|
+
) -> Client:
|
|
59
|
+
"""Configure the package-level client. Call once at startup.
|
|
60
|
+
|
|
61
|
+
By default this installs ``sys.excepthook`` / ``threading.excepthook`` so
|
|
62
|
+
uncaught exceptions are reported automatically.
|
|
63
|
+
"""
|
|
64
|
+
global _client
|
|
65
|
+
_client = Client(
|
|
66
|
+
dsn,
|
|
67
|
+
release=release,
|
|
68
|
+
environment=environment,
|
|
69
|
+
send_default_pii=send_default_pii,
|
|
70
|
+
before_send=before_send,
|
|
71
|
+
transport=transport,
|
|
72
|
+
http_timeout=http_timeout,
|
|
73
|
+
install_excepthook=install_excepthook,
|
|
74
|
+
)
|
|
75
|
+
return _client
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_client() -> Client | None:
|
|
79
|
+
"""Return the package-level client (None if ``init`` was not called)."""
|
|
80
|
+
return _client
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def capture_exception(error: BaseException | None = None) -> str:
|
|
84
|
+
"""Report an exception via the package-level client."""
|
|
85
|
+
return _client.capture_exception(error) if _client is not None else ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def capture_message(message: str, level: str = "info") -> str:
|
|
89
|
+
"""Report a message via the package-level client."""
|
|
90
|
+
return _client.capture_message(message, level) if _client is not None else ""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def set_tag(key: str, value: str) -> None:
|
|
94
|
+
"""Attach a tag to subsequent events on the package-level client."""
|
|
95
|
+
if _client is not None:
|
|
96
|
+
_client.set_tag(key, value)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def set_user(user: dict[str, Any] | None) -> None:
|
|
100
|
+
"""Attach a user to subsequent events on the package-level client."""
|
|
101
|
+
if _client is not None:
|
|
102
|
+
_client.set_user(user)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The Client: builds events, applies scrubbing + before_send, sends the envelope."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, Callable, Optional
|
|
10
|
+
|
|
11
|
+
from ._dsn import parse_dsn
|
|
12
|
+
from ._envelope import serialize_envelope
|
|
13
|
+
from ._event import _SDK, base_event, exception_from, new_event_id
|
|
14
|
+
from ._scrub import scrub
|
|
15
|
+
from ._transport import HttpTransport, Transport
|
|
16
|
+
|
|
17
|
+
BeforeSend = Callable[[dict[str, Any]], Optional[dict[str, Any]]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Client:
|
|
21
|
+
"""Captures errors and messages and reports them to a Tiden project.
|
|
22
|
+
|
|
23
|
+
Capture methods never raise into the host application.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
dsn: str,
|
|
29
|
+
*,
|
|
30
|
+
release: str | None = None,
|
|
31
|
+
environment: str | None = None,
|
|
32
|
+
send_default_pii: bool = False,
|
|
33
|
+
before_send: BeforeSend | None = None,
|
|
34
|
+
transport: Transport | None = None,
|
|
35
|
+
http_timeout: float = 2.0,
|
|
36
|
+
install_excepthook: bool = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._dsn = parse_dsn(dsn)
|
|
39
|
+
self._release = release
|
|
40
|
+
self._environment = environment
|
|
41
|
+
self._send_default_pii = send_default_pii
|
|
42
|
+
self._before_send = before_send
|
|
43
|
+
self._transport: Transport = transport or HttpTransport(self._dsn.ingest_url, http_timeout)
|
|
44
|
+
self._lock = threading.Lock()
|
|
45
|
+
self._tags: dict[str, str] = {}
|
|
46
|
+
self._user: dict[str, Any] | None = None
|
|
47
|
+
if install_excepthook:
|
|
48
|
+
self._install_excepthook()
|
|
49
|
+
|
|
50
|
+
def set_tag(self, key: str, value: str) -> None:
|
|
51
|
+
"""Attach a tag to subsequent events."""
|
|
52
|
+
with self._lock:
|
|
53
|
+
self._tags[key] = value
|
|
54
|
+
|
|
55
|
+
def set_user(self, user: dict[str, Any] | None) -> None:
|
|
56
|
+
"""Attach a user to subsequent events (None clears it)."""
|
|
57
|
+
with self._lock:
|
|
58
|
+
self._user = user
|
|
59
|
+
|
|
60
|
+
def capture_exception(
|
|
61
|
+
self,
|
|
62
|
+
error: BaseException | None = None,
|
|
63
|
+
*,
|
|
64
|
+
level: str = "error",
|
|
65
|
+
request: dict[str, Any] | None = None,
|
|
66
|
+
) -> str:
|
|
67
|
+
"""Report an exception. With no argument, captures the active exception."""
|
|
68
|
+
if error is None:
|
|
69
|
+
error = sys.exc_info()[1]
|
|
70
|
+
if error is None:
|
|
71
|
+
return ""
|
|
72
|
+
event = self._new_event(level)
|
|
73
|
+
event["exception"] = exception_from(error)
|
|
74
|
+
if request is not None:
|
|
75
|
+
event["request"] = request
|
|
76
|
+
return self.capture_event(event)
|
|
77
|
+
|
|
78
|
+
def capture_message(self, message: str, level: str = "info") -> str:
|
|
79
|
+
"""Report a message at the given level."""
|
|
80
|
+
event = self._new_event(level or "info")
|
|
81
|
+
event["message"] = message
|
|
82
|
+
return self.capture_event(event)
|
|
83
|
+
|
|
84
|
+
def capture_event(self, event: dict[str, Any]) -> str:
|
|
85
|
+
"""Send a pre-built event, filling in any missing required fields."""
|
|
86
|
+
try:
|
|
87
|
+
event.setdefault("event_id", new_event_id())
|
|
88
|
+
event.setdefault("timestamp", time.time())
|
|
89
|
+
event.setdefault("platform", "python")
|
|
90
|
+
event.setdefault("level", "error")
|
|
91
|
+
event.setdefault("sdk", dict(_SDK))
|
|
92
|
+
|
|
93
|
+
if not self._send_default_pii:
|
|
94
|
+
event = scrub(event)
|
|
95
|
+
if self._before_send is not None:
|
|
96
|
+
result = self._before_send(event)
|
|
97
|
+
if result is None:
|
|
98
|
+
return ""
|
|
99
|
+
event = result
|
|
100
|
+
|
|
101
|
+
self._transport.send(serialize_envelope(event))
|
|
102
|
+
event_id = event.get("event_id", "")
|
|
103
|
+
return event_id if isinstance(event_id, str) else ""
|
|
104
|
+
except Exception:
|
|
105
|
+
# Monitoring must never crash the app it monitors.
|
|
106
|
+
return ""
|
|
107
|
+
|
|
108
|
+
def _new_event(self, level: str) -> dict[str, Any]:
|
|
109
|
+
event = base_event(level, self._release, self._environment)
|
|
110
|
+
with self._lock:
|
|
111
|
+
if self._tags:
|
|
112
|
+
event["tags"] = dict(self._tags)
|
|
113
|
+
if self._user is not None:
|
|
114
|
+
event["user"] = self._user
|
|
115
|
+
return event
|
|
116
|
+
|
|
117
|
+
def _install_excepthook(self) -> None:
|
|
118
|
+
prev = sys.excepthook
|
|
119
|
+
|
|
120
|
+
def hook(
|
|
121
|
+
exc_type: type[BaseException],
|
|
122
|
+
exc_value: BaseException,
|
|
123
|
+
exc_tb: TracebackType | None,
|
|
124
|
+
) -> None:
|
|
125
|
+
try:
|
|
126
|
+
event = self._new_event("fatal")
|
|
127
|
+
event["exception"] = exception_from(exc_value)
|
|
128
|
+
self.capture_event(event)
|
|
129
|
+
finally:
|
|
130
|
+
prev(exc_type, exc_value, exc_tb)
|
|
131
|
+
|
|
132
|
+
sys.excepthook = hook
|
|
133
|
+
|
|
134
|
+
prev_thread = threading.excepthook
|
|
135
|
+
|
|
136
|
+
def thread_hook(args: threading.ExceptHookArgs) -> None:
|
|
137
|
+
try:
|
|
138
|
+
if args.exc_value is not None:
|
|
139
|
+
event = self._new_event("fatal")
|
|
140
|
+
event["exception"] = exception_from(args.exc_value)
|
|
141
|
+
self.capture_event(event)
|
|
142
|
+
finally:
|
|
143
|
+
prev_thread(args)
|
|
144
|
+
|
|
145
|
+
threading.excepthook = thread_hook
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""DSN parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.parse import quote, urlparse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Dsn:
|
|
11
|
+
"""A parsed Tiden DSN."""
|
|
12
|
+
|
|
13
|
+
ingest_url: str
|
|
14
|
+
public_key: str
|
|
15
|
+
project_id: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_dsn(dsn: str) -> Dsn:
|
|
19
|
+
"""Turn ``http://<publicKey>@host[:port]/<projectId>`` into the ingest URL.
|
|
20
|
+
|
|
21
|
+
The Tiden edge expects ``<scheme>://host/api/<projectId>/envelope/?tiden_key=<publicKey>``
|
|
22
|
+
(wire-compatible with the other Tiden SDKs — the edge reads the ``tiden_key``
|
|
23
|
+
query param).
|
|
24
|
+
"""
|
|
25
|
+
u = urlparse(dsn)
|
|
26
|
+
public_key = u.username or ""
|
|
27
|
+
project_id = u.path.lstrip("/").split("/", 1)[0] if u.path else ""
|
|
28
|
+
|
|
29
|
+
if not u.scheme or not u.hostname or not public_key or not project_id:
|
|
30
|
+
raise ValueError("tiden: invalid DSN (expected http://<publicKey>@host/<projectId>)")
|
|
31
|
+
|
|
32
|
+
host = u.hostname
|
|
33
|
+
if u.port is not None:
|
|
34
|
+
host = f"{host}:{u.port}"
|
|
35
|
+
|
|
36
|
+
ingest_url = (
|
|
37
|
+
f"{u.scheme}://{host}/api/{quote(project_id, safe='')}"
|
|
38
|
+
f"/envelope/?tiden_key={quote(public_key, safe='')}"
|
|
39
|
+
)
|
|
40
|
+
return Dsn(ingest_url=ingest_url, public_key=public_key, project_id=project_id)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Envelope serialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
# Envelope media type the ingest backend accepts. Part of the wire contract.
|
|
10
|
+
CONTENT_TYPE = "application/x-tiden-envelope"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def serialize_envelope(event: dict[str, Any]) -> bytes:
|
|
14
|
+
"""Produce the bytes the edge parses.
|
|
15
|
+
|
|
16
|
+
Framing: ``{envelope header}\\n{item header (with byte length)}\\n{event body}\\n``
|
|
17
|
+
"""
|
|
18
|
+
body = json.dumps(event, separators=(",", ":"), ensure_ascii=False, default=str).encode("utf-8")
|
|
19
|
+
|
|
20
|
+
header = json.dumps(
|
|
21
|
+
{
|
|
22
|
+
"event_id": event.get("event_id"),
|
|
23
|
+
"sent_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
24
|
+
},
|
|
25
|
+
separators=(",", ":"),
|
|
26
|
+
).encode("utf-8")
|
|
27
|
+
|
|
28
|
+
item = json.dumps(
|
|
29
|
+
{
|
|
30
|
+
"type": "event",
|
|
31
|
+
"length": len(body), # byte length used by the edge for framing
|
|
32
|
+
"content_type": "application/json",
|
|
33
|
+
},
|
|
34
|
+
separators=(",", ":"),
|
|
35
|
+
).encode("utf-8")
|
|
36
|
+
|
|
37
|
+
return header + b"\n" + item + b"\n" + body + b"\n"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Event construction: ids, base fields, exception normalization, stack frames."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
import uuid
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
VERSION = "0.1.0"
|
|
13
|
+
|
|
14
|
+
_SDK = {"name": "tiden.python", "version": VERSION}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def new_event_id() -> str:
|
|
18
|
+
"""Return a 32-char hex UUIDv4 (no dashes) — the event_id shape the edge stores."""
|
|
19
|
+
return uuid.uuid4().hex
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def base_event(level: str, release: str | None, environment: str | None) -> dict[str, Any]:
|
|
23
|
+
"""Build the common event fields."""
|
|
24
|
+
event: dict[str, Any] = {
|
|
25
|
+
"event_id": new_event_id(),
|
|
26
|
+
"timestamp": time.time(),
|
|
27
|
+
"platform": "python",
|
|
28
|
+
"level": level,
|
|
29
|
+
"sdk": dict(_SDK),
|
|
30
|
+
}
|
|
31
|
+
if release:
|
|
32
|
+
event["release"] = release
|
|
33
|
+
if environment:
|
|
34
|
+
event["environment"] = environment
|
|
35
|
+
return event
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def exception_from(exc: BaseException) -> dict[str, Any]:
|
|
39
|
+
"""Normalize an exception (and its cause/context chain) into exception.values.
|
|
40
|
+
|
|
41
|
+
Values are ordered with the root cause first — the order the UI expects.
|
|
42
|
+
"""
|
|
43
|
+
chain: list[BaseException] = []
|
|
44
|
+
seen: set[int] = set()
|
|
45
|
+
cur: BaseException | None = exc
|
|
46
|
+
while cur is not None and id(cur) not in seen:
|
|
47
|
+
seen.add(id(cur))
|
|
48
|
+
chain.append(cur)
|
|
49
|
+
nxt = cur.__cause__
|
|
50
|
+
if nxt is None and not cur.__suppress_context__:
|
|
51
|
+
nxt = cur.__context__
|
|
52
|
+
cur = nxt
|
|
53
|
+
|
|
54
|
+
values: list[dict[str, Any]] = []
|
|
55
|
+
for e in reversed(chain):
|
|
56
|
+
values.append(
|
|
57
|
+
{
|
|
58
|
+
"type": type(e).__name__,
|
|
59
|
+
"value": str(e),
|
|
60
|
+
"stacktrace": {"frames": _frames_from(e.__traceback__)},
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
return {"values": values}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _frames_from(tb: TracebackType | None) -> list[dict[str, Any]]:
|
|
67
|
+
# extract_tb yields outermost first with the raise site last — the order
|
|
68
|
+
# the UI expects (crash frame last).
|
|
69
|
+
frames: list[dict[str, Any]] = []
|
|
70
|
+
for fs in traceback.extract_tb(tb):
|
|
71
|
+
frames.append(_frame(fs.filename, fs.lineno, fs.name))
|
|
72
|
+
return frames
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _frame(filename: str | None, lineno: int | None, function: str | None) -> dict[str, Any]:
|
|
76
|
+
frame: dict[str, Any] = {}
|
|
77
|
+
if function:
|
|
78
|
+
frame["function"] = function
|
|
79
|
+
if filename:
|
|
80
|
+
frame["filename"] = _relpath(filename)
|
|
81
|
+
frame["abs_path"] = filename
|
|
82
|
+
if lineno:
|
|
83
|
+
frame["lineno"] = lineno
|
|
84
|
+
frame["in_app"] = _in_app(filename)
|
|
85
|
+
return frame
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _in_app(filename: str | None) -> bool:
|
|
89
|
+
if not filename:
|
|
90
|
+
return False
|
|
91
|
+
# Heuristic: third-party + standard library are not "in app".
|
|
92
|
+
return "site-packages" not in filename and "lib/python" not in filename.replace("\\", "/")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _relpath(filename: str) -> str:
|
|
96
|
+
try:
|
|
97
|
+
cwd = os.getcwd()
|
|
98
|
+
except OSError:
|
|
99
|
+
return filename
|
|
100
|
+
prefix = cwd + os.sep
|
|
101
|
+
if filename.startswith(prefix):
|
|
102
|
+
return filename[len(prefix) :]
|
|
103
|
+
return filename
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""PII scrubbing (applied unless send_default_pii is set)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
REDACTED = "[Filtered]"
|
|
8
|
+
|
|
9
|
+
_HEADER_DENYLIST = frozenset(
|
|
10
|
+
{"authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token", "proxy-authorization"}
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
_KEY_DENYLIST = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"password",
|
|
16
|
+
"passwd",
|
|
17
|
+
"secret",
|
|
18
|
+
"token",
|
|
19
|
+
"api_key",
|
|
20
|
+
"apikey",
|
|
21
|
+
"authorization",
|
|
22
|
+
"credit_card",
|
|
23
|
+
"card_number",
|
|
24
|
+
"cvv",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def scrub(event: dict[str, Any]) -> dict[str, Any]:
|
|
30
|
+
"""Redact likely-PII in request headers and extra/user/contexts keys."""
|
|
31
|
+
request = event.get("request")
|
|
32
|
+
if isinstance(request, dict):
|
|
33
|
+
headers = request.get("headers")
|
|
34
|
+
if isinstance(headers, dict):
|
|
35
|
+
for name in list(headers):
|
|
36
|
+
if isinstance(name, str) and name.lower() in _HEADER_DENYLIST:
|
|
37
|
+
headers[name] = REDACTED
|
|
38
|
+
|
|
39
|
+
for section in ("extra", "user", "contexts"):
|
|
40
|
+
value = event.get(section)
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
event[section] = _scrub_keys(value)
|
|
43
|
+
|
|
44
|
+
return event
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _scrub_keys(data: dict[str, Any]) -> dict[str, Any]:
|
|
48
|
+
for key, value in list(data.items()):
|
|
49
|
+
if isinstance(key, str) and key.lower() in _KEY_DENYLIST:
|
|
50
|
+
data[key] = REDACTED
|
|
51
|
+
elif isinstance(value, dict):
|
|
52
|
+
data[key] = _scrub_keys(value)
|
|
53
|
+
return data
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Synchronous HTTP transport (stdlib only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from ._envelope import CONTENT_TYPE
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Transport(Protocol):
|
|
14
|
+
"""Delivers a serialized envelope to the ingest edge."""
|
|
15
|
+
|
|
16
|
+
def send(self, envelope: bytes) -> None: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HttpTransport:
|
|
20
|
+
"""Posts envelopes synchronously via urllib.
|
|
21
|
+
|
|
22
|
+
Every failure is swallowed — monitoring must never crash the host app.
|
|
23
|
+
Honors HTTP 429 + Retry-After with a process-local gate.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, url: str, timeout: float = 2.0) -> None:
|
|
27
|
+
self._url = url
|
|
28
|
+
self._timeout = timeout
|
|
29
|
+
self._rate_limited_until = 0.0
|
|
30
|
+
|
|
31
|
+
def send(self, envelope: bytes) -> None:
|
|
32
|
+
if time.time() < self._rate_limited_until:
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
request = urllib.request.Request( # noqa: S310 (scheme comes from a trusted DSN)
|
|
36
|
+
self._url,
|
|
37
|
+
data=envelope,
|
|
38
|
+
method="POST",
|
|
39
|
+
headers={"Content-Type": CONTENT_TYPE},
|
|
40
|
+
)
|
|
41
|
+
try:
|
|
42
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as resp: # noqa: S310
|
|
43
|
+
resp.read()
|
|
44
|
+
except urllib.error.HTTPError as exc:
|
|
45
|
+
if exc.code == 429:
|
|
46
|
+
self._rate_limited_until = time.time() + _retry_after(exc)
|
|
47
|
+
except Exception:
|
|
48
|
+
# Monitoring must never crash the app it monitors.
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _retry_after(exc: urllib.error.HTTPError) -> float:
|
|
53
|
+
if exc.headers is not None:
|
|
54
|
+
value = exc.headers.get("Retry-After")
|
|
55
|
+
if value is not None and value.strip().isdigit():
|
|
56
|
+
return float(value.strip())
|
|
57
|
+
return 60.0
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""A logging.Handler that forwards records to Tiden.
|
|
2
|
+
|
|
3
|
+
Records with exception info become captured exceptions; others become messages::
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from tiden_telemetry.logging import TidenLoggingHandler
|
|
7
|
+
|
|
8
|
+
logging.getLogger().addHandler(TidenLoggingHandler())
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
from . import get_client
|
|
16
|
+
from ._client import Client
|
|
17
|
+
|
|
18
|
+
_LEVEL_MAP = {
|
|
19
|
+
logging.CRITICAL: "fatal",
|
|
20
|
+
logging.ERROR: "error",
|
|
21
|
+
logging.WARNING: "warning",
|
|
22
|
+
logging.INFO: "info",
|
|
23
|
+
logging.DEBUG: "debug",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TidenLoggingHandler(logging.Handler):
|
|
28
|
+
"""Forwards log records at or above ``level`` (default ERROR) to Tiden."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, level: int = logging.ERROR, client: Client | None = None) -> None:
|
|
31
|
+
super().__init__(level)
|
|
32
|
+
self._client = client
|
|
33
|
+
|
|
34
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
35
|
+
client = self._client or get_client()
|
|
36
|
+
if client is None:
|
|
37
|
+
return
|
|
38
|
+
try:
|
|
39
|
+
level = _LEVEL_MAP.get(record.levelno, "error")
|
|
40
|
+
if record.exc_info is not None and record.exc_info[1] is not None:
|
|
41
|
+
client.capture_exception(record.exc_info[1], level=level)
|
|
42
|
+
else:
|
|
43
|
+
client.capture_message(record.getMessage(), level)
|
|
44
|
+
except Exception:
|
|
45
|
+
self.handleError(record)
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""WSGI middleware that reports unhandled exceptions to Tiden."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
7
|
+
|
|
8
|
+
from . import get_client
|
|
9
|
+
from ._client import Client
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
StartResponse = Callable[..., Any]
|
|
13
|
+
WSGIEnvironment = dict[str, Any]
|
|
14
|
+
WSGIApplication = Callable[[WSGIEnvironment, StartResponse], Iterable[bytes]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TidenWsgiMiddleware:
|
|
18
|
+
"""Wrap a WSGI app so unhandled exceptions are captured, then re-raised.
|
|
19
|
+
|
|
20
|
+
Re-raising preserves the server's own error handling; Tiden just observes::
|
|
21
|
+
|
|
22
|
+
app = TidenWsgiMiddleware(app)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, app: WSGIApplication, client: Client | None = None) -> None:
|
|
26
|
+
self._app = app
|
|
27
|
+
self._client = client
|
|
28
|
+
|
|
29
|
+
def __call__(self, environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]:
|
|
30
|
+
try:
|
|
31
|
+
return self._app(environ, start_response)
|
|
32
|
+
except Exception as exc:
|
|
33
|
+
client = self._client or get_client()
|
|
34
|
+
if client is not None:
|
|
35
|
+
client.capture_exception(exc, level="fatal", request=_request_from_environ(environ))
|
|
36
|
+
raise
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _request_from_environ(environ: WSGIEnvironment) -> dict[str, Any]:
|
|
40
|
+
headers: dict[str, str] = {}
|
|
41
|
+
for key, value in environ.items():
|
|
42
|
+
if key.startswith("HTTP_") and isinstance(value, str):
|
|
43
|
+
name = key[5:].replace("_", "-").title()
|
|
44
|
+
headers[name] = value
|
|
45
|
+
|
|
46
|
+
scheme = environ.get("wsgi.url_scheme", "http")
|
|
47
|
+
host = environ.get("HTTP_HOST") or environ.get("SERVER_NAME", "")
|
|
48
|
+
path = environ.get("PATH_INFO", "")
|
|
49
|
+
return {
|
|
50
|
+
"url": f"{scheme}://{host}{path}",
|
|
51
|
+
"method": environ.get("REQUEST_METHOD", ""),
|
|
52
|
+
"headers": headers,
|
|
53
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from tiden_telemetry import Client
|
|
10
|
+
from tiden_telemetry.logging import TidenLoggingHandler
|
|
11
|
+
from tiden_telemetry.wsgi import TidenWsgiMiddleware
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CaptureTransport:
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self.last: bytes | None = None
|
|
17
|
+
|
|
18
|
+
def send(self, envelope: bytes) -> None:
|
|
19
|
+
self.last = envelope
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def decode_event(envelope: bytes | None) -> dict[str, Any]:
|
|
23
|
+
assert envelope is not None
|
|
24
|
+
lines = envelope.decode("utf-8").rstrip("\n").split("\n")
|
|
25
|
+
assert len(lines) == 3
|
|
26
|
+
return json.loads(lines[2])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def make_client(**opts: Any) -> tuple[Client, CaptureTransport]:
|
|
30
|
+
tr = CaptureTransport()
|
|
31
|
+
client = Client("http://k@host/p", transport=tr, **opts)
|
|
32
|
+
return client, tr
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_capture_message() -> None:
|
|
36
|
+
client, tr = make_client()
|
|
37
|
+
event_id = client.capture_message("checkout completed", "info")
|
|
38
|
+
assert event_id
|
|
39
|
+
event = decode_event(tr.last)
|
|
40
|
+
assert event["message"] == "checkout completed"
|
|
41
|
+
assert event["level"] == "info"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_before_send_drop() -> None:
|
|
45
|
+
client, tr = make_client(before_send=lambda _event: None)
|
|
46
|
+
assert client.capture_exception(ValueError("x")) == ""
|
|
47
|
+
assert tr.last is None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_before_send_mutate() -> None:
|
|
51
|
+
def mutate(event: dict[str, Any]) -> dict[str, Any]:
|
|
52
|
+
event["level"] = "warning"
|
|
53
|
+
return event
|
|
54
|
+
|
|
55
|
+
client, tr = make_client(before_send=mutate)
|
|
56
|
+
client.capture_exception(ValueError("x"))
|
|
57
|
+
assert decode_event(tr.last)["level"] == "warning"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_scrub_extra() -> None:
|
|
61
|
+
client, tr = make_client()
|
|
62
|
+
event = client._new_event("error")
|
|
63
|
+
event["extra"] = {"password": "hunter2", "ok": "keep"}
|
|
64
|
+
client.capture_event(event)
|
|
65
|
+
extra = decode_event(tr.last)["extra"]
|
|
66
|
+
assert extra["password"] == "[Filtered]"
|
|
67
|
+
assert extra["ok"] == "keep"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_send_default_pii_disables_scrub() -> None:
|
|
71
|
+
client, tr = make_client(send_default_pii=True)
|
|
72
|
+
event = client._new_event("error")
|
|
73
|
+
event["extra"] = {"password": "hunter2"}
|
|
74
|
+
client.capture_event(event)
|
|
75
|
+
assert decode_event(tr.last)["extra"]["password"] == "hunter2"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_set_tag_and_user() -> None:
|
|
79
|
+
client, tr = make_client()
|
|
80
|
+
client.set_tag("plan", "pro")
|
|
81
|
+
client.set_user({"id": "u_1"})
|
|
82
|
+
client.capture_message("hi", "info")
|
|
83
|
+
event = decode_event(tr.last)
|
|
84
|
+
assert event["tags"]["plan"] == "pro"
|
|
85
|
+
assert event["user"]["id"] == "u_1"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_capture_no_active_exception() -> None:
|
|
89
|
+
client, tr = make_client()
|
|
90
|
+
assert client.capture_exception() == ""
|
|
91
|
+
assert tr.last is None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_exception_chain_root_first() -> None:
|
|
95
|
+
client, tr = make_client()
|
|
96
|
+
try:
|
|
97
|
+
try:
|
|
98
|
+
raise ValueError("root")
|
|
99
|
+
except ValueError as root:
|
|
100
|
+
raise RuntimeError("wrapper") from root
|
|
101
|
+
except RuntimeError:
|
|
102
|
+
client.capture_exception()
|
|
103
|
+
values = decode_event(tr.last)["exception"]["values"]
|
|
104
|
+
assert [v["type"] for v in values] == ["ValueError", "RuntimeError"]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_wsgi_middleware_captures_and_reraises() -> None:
|
|
108
|
+
tr = CaptureTransport()
|
|
109
|
+
client = Client("http://k@host/p", transport=tr)
|
|
110
|
+
|
|
111
|
+
def app(environ: dict[str, Any], start_response: Any) -> list[bytes]:
|
|
112
|
+
raise RuntimeError("handler boom")
|
|
113
|
+
|
|
114
|
+
wrapped = TidenWsgiMiddleware(app, client=client)
|
|
115
|
+
environ = {
|
|
116
|
+
"REQUEST_METHOD": "GET",
|
|
117
|
+
"PATH_INFO": "/x",
|
|
118
|
+
"HTTP_HOST": "example",
|
|
119
|
+
"wsgi.url_scheme": "http",
|
|
120
|
+
"HTTP_AUTHORIZATION": "secret",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
with pytest.raises(RuntimeError):
|
|
124
|
+
wrapped(environ, lambda *_: None)
|
|
125
|
+
|
|
126
|
+
event = decode_event(tr.last)
|
|
127
|
+
ex = event["exception"]["values"][0]
|
|
128
|
+
assert ex["value"] == "handler boom"
|
|
129
|
+
assert event["request"]["method"] == "GET"
|
|
130
|
+
assert event["request"]["headers"]["Authorization"] == "[Filtered]"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_logging_handler() -> None:
|
|
134
|
+
tr = CaptureTransport()
|
|
135
|
+
client = Client("http://k@host/p", transport=tr)
|
|
136
|
+
logger = logging.getLogger("tiden-test")
|
|
137
|
+
logger.setLevel(logging.ERROR)
|
|
138
|
+
handler = TidenLoggingHandler(client=client)
|
|
139
|
+
logger.addHandler(handler)
|
|
140
|
+
try:
|
|
141
|
+
try:
|
|
142
|
+
raise ValueError("logged boom")
|
|
143
|
+
except ValueError:
|
|
144
|
+
logger.exception("something failed")
|
|
145
|
+
finally:
|
|
146
|
+
logger.removeHandler(handler)
|
|
147
|
+
|
|
148
|
+
event = decode_event(tr.last)
|
|
149
|
+
assert event["exception"]["values"][0]["value"] == "logged boom"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Wire contract test: pins the exact bytes the SDK puts on the wire.
|
|
2
|
+
|
|
3
|
+
If the backend changes the ingest interface (auth param, media type, envelope
|
|
4
|
+
framing, or event field names), update the SDK + these assertions together — a
|
|
5
|
+
drift makes this test fail loudly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from tiden_telemetry import Client
|
|
13
|
+
from tiden_telemetry._dsn import parse_dsn
|
|
14
|
+
from tiden_telemetry._envelope import CONTENT_TYPE
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CaptureTransport:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self.last: bytes | None = None
|
|
20
|
+
|
|
21
|
+
def send(self, envelope: bytes) -> None:
|
|
22
|
+
self.last = envelope
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_dsn_ingest_url_and_auth_param() -> None:
|
|
26
|
+
dsn = parse_dsn("http://pub@host:1140/proj-1")
|
|
27
|
+
assert dsn.ingest_url == "http://host:1140/api/proj-1/envelope/?tiden_key=pub"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_envelope_media_type() -> None:
|
|
31
|
+
assert CONTENT_TYPE == "application/x-tiden-envelope"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_envelope_framing_and_event_shape() -> None:
|
|
35
|
+
tr = CaptureTransport()
|
|
36
|
+
client = Client(
|
|
37
|
+
"http://pub@host:1140/proj-1",
|
|
38
|
+
release="app@1.2.3",
|
|
39
|
+
environment="production",
|
|
40
|
+
transport=tr,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
raise ValueError("boom")
|
|
45
|
+
except ValueError:
|
|
46
|
+
client.capture_exception()
|
|
47
|
+
|
|
48
|
+
assert tr.last is not None
|
|
49
|
+
lines = tr.last.decode("utf-8").rstrip("\n").split("\n")
|
|
50
|
+
assert len(lines) == 3
|
|
51
|
+
|
|
52
|
+
header = json.loads(lines[0])
|
|
53
|
+
item = json.loads(lines[1])
|
|
54
|
+
event = json.loads(lines[2])
|
|
55
|
+
|
|
56
|
+
# framing
|
|
57
|
+
assert item["type"] == "event"
|
|
58
|
+
assert item["content_type"] == "application/json"
|
|
59
|
+
assert item["length"] == len(lines[2].encode("utf-8")) # byte length
|
|
60
|
+
assert header["event_id"] == event["event_id"]
|
|
61
|
+
|
|
62
|
+
# event schema the backend normalizer reads
|
|
63
|
+
assert event["platform"] == "python"
|
|
64
|
+
assert event["level"] == "error"
|
|
65
|
+
assert event["release"] == "app@1.2.3"
|
|
66
|
+
assert event["environment"] == "production"
|
|
67
|
+
assert event["sdk"]["name"] == "tiden.python"
|
|
68
|
+
|
|
69
|
+
ex = event["exception"]["values"][0]
|
|
70
|
+
assert ex["type"] == "ValueError"
|
|
71
|
+
assert ex["value"] == "boom"
|
|
72
|
+
assert ex["stacktrace"]["frames"]
|
|
73
|
+
last = ex["stacktrace"]["frames"][-1]
|
|
74
|
+
assert "function" in last
|
|
75
|
+
assert "in_app" in last
|
|
76
|
+
|
|
77
|
+
# event_id shape: 32 hex chars
|
|
78
|
+
assert len(event["event_id"]) == 32
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from tiden_telemetry._dsn import parse_dsn
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_with_port() -> None:
|
|
9
|
+
dsn = parse_dsn("http://pub@host:1140/proj-1")
|
|
10
|
+
assert dsn.ingest_url == "http://host:1140/api/proj-1/envelope/?tiden_key=pub"
|
|
11
|
+
assert dsn.public_key == "pub"
|
|
12
|
+
assert dsn.project_id == "proj-1"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_no_port_https() -> None:
|
|
16
|
+
dsn = parse_dsn("https://k@ingest.tiden.ai/42")
|
|
17
|
+
assert dsn.ingest_url == "https://ingest.tiden.ai/api/42/envelope/?tiden_key=k"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.mark.parametrize(
|
|
21
|
+
"bad",
|
|
22
|
+
[
|
|
23
|
+
"http://host/proj", # missing key
|
|
24
|
+
"http://pub@host/", # missing project
|
|
25
|
+
"not-a-url",
|
|
26
|
+
"",
|
|
27
|
+
],
|
|
28
|
+
)
|
|
29
|
+
def test_invalid(bad: str) -> None:
|
|
30
|
+
with pytest.raises(ValueError):
|
|
31
|
+
parse_dsn(bad)
|