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.
@@ -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,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .pytest_cache/
@@ -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
+ [![CI](https://github.com/qase-tms/tiden-telemetry-python/actions/workflows/ci.yml/badge.svg)](https://github.com/qase-tms/tiden-telemetry-python/actions/workflows/ci.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/tiden-telemetry)](https://pypi.org/project/tiden-telemetry/)
34
+ [![Python](https://img.shields.io/pypi/pyversions/tiden-telemetry)](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
+ [![CI](https://github.com/qase-tms/tiden-telemetry-python/actions/workflows/ci.yml/badge.svg)](https://github.com/qase-tms/tiden-telemetry-python/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/tiden-telemetry)](https://pypi.org/project/tiden-telemetry/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/tiden-telemetry)](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)