errorgap 0.1.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- errorgap-0.3.0/CHANGELOG.md +30 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/PKG-INFO +68 -2
- {errorgap-0.1.1 → errorgap-0.3.0}/README.md +66 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/pyproject.toml +1 -1
- {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/__init__.py +78 -1
- errorgap-0.3.0/src/errorgap/apm.py +131 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/client.py +40 -8
- {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/django.py +57 -2
- errorgap-0.3.0/src/errorgap/fastapi.py +120 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/flask.py +28 -3
- errorgap-0.3.0/src/errorgap/transaction_context.py +32 -0
- errorgap-0.3.0/src/errorgap/version.py +1 -0
- errorgap-0.3.0/src/errorgap/wsgi.py +109 -0
- errorgap-0.3.0/tests/test_apm.py +120 -0
- errorgap-0.3.0/tests/test_django.py +111 -0
- errorgap-0.3.0/tests/test_fastapi.py +82 -0
- errorgap-0.3.0/tests/test_flask.py +74 -0
- errorgap-0.1.1/CHANGELOG.md +0 -9
- errorgap-0.1.1/src/errorgap/fastapi.py +0 -67
- errorgap-0.1.1/src/errorgap/version.py +0 -1
- errorgap-0.1.1/tests/test_django.py +0 -65
- errorgap-0.1.1/tests/test_fastapi.py +0 -39
- errorgap-0.1.1/tests/test_flask.py +0 -38
- {errorgap-0.1.1 → errorgap-0.3.0}/.github/workflows/ci.yml +0 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/.github/workflows/release.yml +0 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/.gitignore +0 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/LICENSE +0 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/backtrace.py +0 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/configuration.py +0 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/filter.py +0 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/handlers.py +0 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/notice.py +0 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/tests/conftest.py +0 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/tests/test_backtrace.py +0 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/tests/test_client.py +0 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/tests/test_configuration.py +0 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/tests/test_filter.py +0 -0
- {errorgap-0.1.1 → errorgap-0.3.0}/tests/test_notice.py +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0 — 2026-10-04
|
|
4
|
+
|
|
5
|
+
- Django and FastAPI/Starlette middleware now time each request as an APM
|
|
6
|
+
transaction (with `apm_enabled`), grouped by URL pattern / route path, with
|
|
7
|
+
errors linked to their request and the browser's `x-errorgap-trace` header
|
|
8
|
+
recorded. `errorgap.django.spans(request)` / `errorgap.fastapi.spans(request)`
|
|
9
|
+
record spans.
|
|
10
|
+
|
|
11
|
+
## 0.2.0 — 2026-10-03
|
|
12
|
+
|
|
13
|
+
- APM: `track_transaction` / `track_job` context managers, `notify_transaction`,
|
|
14
|
+
and `SpanCollector` for DB and HTTP spans; enabled with `apm_enabled=True`,
|
|
15
|
+
sampled by `apm_sample_rate`.
|
|
16
|
+
- Errors reported during a transaction carry its id as
|
|
17
|
+
`context.transaction_id` (held in a `ContextVar`), linking each error to
|
|
18
|
+
the request or job that raised it.
|
|
19
|
+
- `errorgap.wsgi.ErrorgapMiddleware` times WSGI requests; `errorgap.flask.init_app`
|
|
20
|
+
installs it and groups requests by URL rule.
|
|
21
|
+
- Requests record the browser SDK's `x-errorgap-trace` header as the
|
|
22
|
+
transaction's `trace_id`, linking browser API calls to server requests.
|
|
23
|
+
|
|
24
|
+
## 0.1.1 — 2026-07-18
|
|
25
|
+
|
|
26
|
+
- Include bounded inline source excerpts for readable application and dependency
|
|
27
|
+
traceback frames so
|
|
28
|
+
Errorgap can render highlighted source without a repository integration.
|
|
29
|
+
- Order traceback frames innermost-first so frame 0 and group fingerprints use
|
|
30
|
+
the actual exception site.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: errorgap
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python notifier for Errorgap error tracking.
|
|
5
5
|
Project-URL: Homepage, https://github.com/errorgaphq/errorgap-python
|
|
6
6
|
Project-URL: Source, https://github.com/errorgaphq/errorgap-python
|
|
@@ -97,6 +97,11 @@ MIDDLEWARE = [
|
|
|
97
97
|
]
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
+
With `apm_enabled=True` each request is also an APM transaction grouped by
|
|
101
|
+
its URL pattern (`/orders/<int:order_id>`); errors raised during it carry its
|
|
102
|
+
transaction id, and the browser SDK's `x-errorgap-trace` header is recorded.
|
|
103
|
+
Record spans from a view with `errorgap.django.spans(request).database(sql, ms)`.
|
|
104
|
+
|
|
100
105
|
## Flask
|
|
101
106
|
|
|
102
107
|
```python
|
|
@@ -107,6 +112,59 @@ app = Flask(__name__)
|
|
|
107
112
|
init_app(app)
|
|
108
113
|
```
|
|
109
114
|
|
|
115
|
+
`init_app` also times each request as an APM transaction (sent with
|
|
116
|
+
`apm_enabled=True`) grouped by its URL rule (`/orders/<int:id>`), and errors
|
|
117
|
+
raised during the request carry its transaction id. Record spans from a view
|
|
118
|
+
with `errorgap.flask.spans()`:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from errorgap.flask import spans
|
|
122
|
+
|
|
123
|
+
@app.route("/orders/<int:order_id>")
|
|
124
|
+
def order(order_id):
|
|
125
|
+
started = time.perf_counter()
|
|
126
|
+
row = db.execute("SELECT * FROM orders WHERE id = %s", (order_id,)).fetchone()
|
|
127
|
+
spans().database("SELECT * FROM orders WHERE id = %s", (time.perf_counter() - started) * 1000)
|
|
128
|
+
return row
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## WSGI
|
|
132
|
+
|
|
133
|
+
Any WSGI app gets the same request transactions from
|
|
134
|
+
`errorgap.wsgi.ErrorgapMiddleware`. Set `environ["errorgap.route"]` to the
|
|
135
|
+
matched route template to group requests (otherwise the raw path is used).
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from errorgap.wsgi import ErrorgapMiddleware
|
|
139
|
+
|
|
140
|
+
application = ErrorgapMiddleware(application)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Transactions and jobs
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
with errorgap.track_transaction("GET", "/orders/{id}", "/orders/123") as txn:
|
|
147
|
+
txn.spans.database("SELECT * FROM orders WHERE id = 123", 4.2)
|
|
148
|
+
txn.status_code = 200
|
|
149
|
+
|
|
150
|
+
with errorgap.track_job("ReceiptJob", queue="mailers") as txn:
|
|
151
|
+
send_receipt()
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Both time the block and deliver on exit even if it raises. Errors reported
|
|
155
|
+
inside carry the transaction id (`context.transaction_id`), so errorgap shows
|
|
156
|
+
the error a request raised on its trace. The id lives in a `ContextVar`, so
|
|
157
|
+
concurrent threads and asyncio tasks never share one;
|
|
158
|
+
`errorgap.current_transaction_id()` returns the id in effect.
|
|
159
|
+
|
|
160
|
+
## Browser trace links
|
|
161
|
+
|
|
162
|
+
When the errorgap browser SDK (`@errorgap/browser` 0.3+) is on the page, its
|
|
163
|
+
API calls send an `x-errorgap-trace` header. The WSGI, Django and FastAPI
|
|
164
|
+
middleware (and so Flask's `init_app`) record it, and `track_transaction(..., trace_id=header)` accepts
|
|
165
|
+
it, so errorgap's browser Performance view links each call to the server
|
|
166
|
+
request that answered it. Malformed values are ignored.
|
|
167
|
+
|
|
110
168
|
## FastAPI
|
|
111
169
|
|
|
112
170
|
```python
|
|
@@ -117,6 +175,12 @@ app = FastAPI()
|
|
|
117
175
|
app.add_middleware(ErrorgapMiddleware)
|
|
118
176
|
```
|
|
119
177
|
|
|
178
|
+
With `apm_enabled=True` each request is also an APM transaction grouped by
|
|
179
|
+
its route path (`/orders/{order_id}`); errors raised during it carry its
|
|
180
|
+
transaction id, and the browser SDK's `x-errorgap-trace` header is recorded.
|
|
181
|
+
Record spans from an endpoint with `errorgap.fastapi.spans(request).database(sql, ms)`
|
|
182
|
+
(take `request: Request` as a parameter).
|
|
183
|
+
|
|
120
184
|
## Configuration reference
|
|
121
185
|
|
|
122
186
|
| Argument | Default | Notes |
|
|
@@ -130,6 +194,8 @@ app.add_middleware(ErrorgapMiddleware)
|
|
|
130
194
|
| `async_` | `True` | Background-thread delivery |
|
|
131
195
|
| `logger` | `logging.getLogger("errorgap")` | Pass `None` to silence |
|
|
132
196
|
| `filter_keys` | `("password", "token", ...)` | Substring match, case-insensitive |
|
|
197
|
+
| `apm_enabled` | `False` | Send APM transactions |
|
|
198
|
+
| `apm_sample_rate` | `1.0` | Fraction of transactions sent (errors are unaffected) |
|
|
133
199
|
| `capture_globals` | `True` | Install `sys.excepthook` |
|
|
134
200
|
|
|
135
201
|
## Graceful shutdown
|
|
@@ -61,6 +61,11 @@ MIDDLEWARE = [
|
|
|
61
61
|
]
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
With `apm_enabled=True` each request is also an APM transaction grouped by
|
|
65
|
+
its URL pattern (`/orders/<int:order_id>`); errors raised during it carry its
|
|
66
|
+
transaction id, and the browser SDK's `x-errorgap-trace` header is recorded.
|
|
67
|
+
Record spans from a view with `errorgap.django.spans(request).database(sql, ms)`.
|
|
68
|
+
|
|
64
69
|
## Flask
|
|
65
70
|
|
|
66
71
|
```python
|
|
@@ -71,6 +76,59 @@ app = Flask(__name__)
|
|
|
71
76
|
init_app(app)
|
|
72
77
|
```
|
|
73
78
|
|
|
79
|
+
`init_app` also times each request as an APM transaction (sent with
|
|
80
|
+
`apm_enabled=True`) grouped by its URL rule (`/orders/<int:id>`), and errors
|
|
81
|
+
raised during the request carry its transaction id. Record spans from a view
|
|
82
|
+
with `errorgap.flask.spans()`:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from errorgap.flask import spans
|
|
86
|
+
|
|
87
|
+
@app.route("/orders/<int:order_id>")
|
|
88
|
+
def order(order_id):
|
|
89
|
+
started = time.perf_counter()
|
|
90
|
+
row = db.execute("SELECT * FROM orders WHERE id = %s", (order_id,)).fetchone()
|
|
91
|
+
spans().database("SELECT * FROM orders WHERE id = %s", (time.perf_counter() - started) * 1000)
|
|
92
|
+
return row
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## WSGI
|
|
96
|
+
|
|
97
|
+
Any WSGI app gets the same request transactions from
|
|
98
|
+
`errorgap.wsgi.ErrorgapMiddleware`. Set `environ["errorgap.route"]` to the
|
|
99
|
+
matched route template to group requests (otherwise the raw path is used).
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from errorgap.wsgi import ErrorgapMiddleware
|
|
103
|
+
|
|
104
|
+
application = ErrorgapMiddleware(application)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Transactions and jobs
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
with errorgap.track_transaction("GET", "/orders/{id}", "/orders/123") as txn:
|
|
111
|
+
txn.spans.database("SELECT * FROM orders WHERE id = 123", 4.2)
|
|
112
|
+
txn.status_code = 200
|
|
113
|
+
|
|
114
|
+
with errorgap.track_job("ReceiptJob", queue="mailers") as txn:
|
|
115
|
+
send_receipt()
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Both time the block and deliver on exit even if it raises. Errors reported
|
|
119
|
+
inside carry the transaction id (`context.transaction_id`), so errorgap shows
|
|
120
|
+
the error a request raised on its trace. The id lives in a `ContextVar`, so
|
|
121
|
+
concurrent threads and asyncio tasks never share one;
|
|
122
|
+
`errorgap.current_transaction_id()` returns the id in effect.
|
|
123
|
+
|
|
124
|
+
## Browser trace links
|
|
125
|
+
|
|
126
|
+
When the errorgap browser SDK (`@errorgap/browser` 0.3+) is on the page, its
|
|
127
|
+
API calls send an `x-errorgap-trace` header. The WSGI, Django and FastAPI
|
|
128
|
+
middleware (and so Flask's `init_app`) record it, and `track_transaction(..., trace_id=header)` accepts
|
|
129
|
+
it, so errorgap's browser Performance view links each call to the server
|
|
130
|
+
request that answered it. Malformed values are ignored.
|
|
131
|
+
|
|
74
132
|
## FastAPI
|
|
75
133
|
|
|
76
134
|
```python
|
|
@@ -81,6 +139,12 @@ app = FastAPI()
|
|
|
81
139
|
app.add_middleware(ErrorgapMiddleware)
|
|
82
140
|
```
|
|
83
141
|
|
|
142
|
+
With `apm_enabled=True` each request is also an APM transaction grouped by
|
|
143
|
+
its route path (`/orders/{order_id}`); errors raised during it carry its
|
|
144
|
+
transaction id, and the browser SDK's `x-errorgap-trace` header is recorded.
|
|
145
|
+
Record spans from an endpoint with `errorgap.fastapi.spans(request).database(sql, ms)`
|
|
146
|
+
(take `request: Request` as a parameter).
|
|
147
|
+
|
|
84
148
|
## Configuration reference
|
|
85
149
|
|
|
86
150
|
| Argument | Default | Notes |
|
|
@@ -94,6 +158,8 @@ app.add_middleware(ErrorgapMiddleware)
|
|
|
94
158
|
| `async_` | `True` | Background-thread delivery |
|
|
95
159
|
| `logger` | `logging.getLogger("errorgap")` | Pass `None` to silence |
|
|
96
160
|
| `filter_keys` | `("password", "token", ...)` | Substring match, case-insensitive |
|
|
161
|
+
| `apm_enabled` | `False` | Send APM transactions |
|
|
162
|
+
| `apm_sample_rate` | `1.0` | Fraction of transactions sent (errors are unaffected) |
|
|
97
163
|
| `capture_globals` | `True` | Install `sys.excepthook` |
|
|
98
164
|
|
|
99
165
|
## Graceful shutdown
|
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import contextlib
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Dict, Iterator, Optional
|
|
4
7
|
|
|
8
|
+
from .apm import TRACE_HEADER, Span, SpanCollector, Transaction, browser_trace_id, normalize_sql
|
|
5
9
|
from .client import Client, DeliveryResult
|
|
6
10
|
from .configuration import Configuration
|
|
7
11
|
from .handlers import install_excepthook, uninstall_excepthook
|
|
12
|
+
from .transaction_context import current_transaction_id, new_transaction_id, transaction_scope
|
|
8
13
|
from .version import VERSION
|
|
9
14
|
|
|
10
15
|
__all__ = [
|
|
11
16
|
"init",
|
|
12
17
|
"notify",
|
|
18
|
+
"notify_transaction",
|
|
19
|
+
"track_transaction",
|
|
20
|
+
"track_job",
|
|
21
|
+
"current_transaction_id",
|
|
22
|
+
"transaction_scope",
|
|
23
|
+
"browser_trace_id",
|
|
13
24
|
"flush",
|
|
14
25
|
"shutdown",
|
|
15
26
|
"configuration",
|
|
@@ -17,6 +28,11 @@ __all__ = [
|
|
|
17
28
|
"Configuration",
|
|
18
29
|
"Client",
|
|
19
30
|
"DeliveryResult",
|
|
31
|
+
"Span",
|
|
32
|
+
"SpanCollector",
|
|
33
|
+
"Transaction",
|
|
34
|
+
"TRACE_HEADER",
|
|
35
|
+
"normalize_sql",
|
|
20
36
|
"VERSION",
|
|
21
37
|
]
|
|
22
38
|
|
|
@@ -35,6 +51,8 @@ def init(
|
|
|
35
51
|
async_: Optional[bool] = None,
|
|
36
52
|
filter_keys: Optional[tuple] = None,
|
|
37
53
|
logger: Optional[Any] = None,
|
|
54
|
+
apm_enabled: Optional[bool] = None,
|
|
55
|
+
apm_sample_rate: Optional[float] = None,
|
|
38
56
|
capture_globals: bool = True,
|
|
39
57
|
) -> None:
|
|
40
58
|
"""Configure the SDK and install global error hooks.
|
|
@@ -64,6 +82,10 @@ def init(
|
|
|
64
82
|
overrides["filter_keys"] = filter_keys
|
|
65
83
|
if logger is not None:
|
|
66
84
|
overrides["logger"] = logger
|
|
85
|
+
if apm_enabled is not None:
|
|
86
|
+
overrides["apm_enabled"] = apm_enabled
|
|
87
|
+
if apm_sample_rate is not None:
|
|
88
|
+
overrides["apm_sample_rate"] = apm_sample_rate
|
|
67
89
|
|
|
68
90
|
_configuration = Configuration(**overrides)
|
|
69
91
|
_client.configure(_configuration)
|
|
@@ -92,6 +114,61 @@ def notify(
|
|
|
92
114
|
)
|
|
93
115
|
|
|
94
116
|
|
|
117
|
+
def notify_transaction(transaction: Transaction, sync: bool = False) -> DeliveryResult:
|
|
118
|
+
"""Deliver a pre-measured APM transaction."""
|
|
119
|
+
return _client.notify_transaction(transaction, sync=sync)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@contextlib.contextmanager
|
|
123
|
+
def track_transaction(
|
|
124
|
+
method: Optional[str] = None,
|
|
125
|
+
path: Optional[str] = None,
|
|
126
|
+
path_raw: Optional[str] = None,
|
|
127
|
+
trace_id: Optional[str] = None,
|
|
128
|
+
kind: str = "web",
|
|
129
|
+
) -> Iterator[Transaction]:
|
|
130
|
+
"""Time the ``with`` block as an APM transaction and deliver it on exit,
|
|
131
|
+
even if the block raises. Errors reported inside it carry the
|
|
132
|
+
transaction's id. Yields the :class:`Transaction`: record spans on
|
|
133
|
+
``txn.spans`` and set ``txn.status_code``. ``trace_id`` takes the raw
|
|
134
|
+
``x-errorgap-trace`` header value and ignores anything but a UUID."""
|
|
135
|
+
txn = Transaction(
|
|
136
|
+
kind=kind,
|
|
137
|
+
id=new_transaction_id(),
|
|
138
|
+
trace_id=browser_trace_id(trace_id),
|
|
139
|
+
method=method,
|
|
140
|
+
path=path,
|
|
141
|
+
path_raw=path_raw,
|
|
142
|
+
)
|
|
143
|
+
with _timed(txn):
|
|
144
|
+
yield txn
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@contextlib.contextmanager
|
|
148
|
+
def track_job(job_class: str, queue: str = "default") -> Iterator[Transaction]:
|
|
149
|
+
"""Time the ``with`` block as a ``job`` transaction; see
|
|
150
|
+
:func:`track_transaction`."""
|
|
151
|
+
txn = Transaction(kind="job", id=new_transaction_id(), job_class=job_class, queue=queue)
|
|
152
|
+
with _timed(txn):
|
|
153
|
+
yield txn
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@contextlib.contextmanager
|
|
157
|
+
def _timed(txn: Transaction) -> Iterator[None]:
|
|
158
|
+
txn.occurred_at = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
159
|
+
start = time.perf_counter()
|
|
160
|
+
try:
|
|
161
|
+
with transaction_scope(txn.id or new_transaction_id()):
|
|
162
|
+
yield
|
|
163
|
+
except BaseException:
|
|
164
|
+
if txn.kind == "web" and txn.status_code is None:
|
|
165
|
+
txn.status_code = 500
|
|
166
|
+
raise
|
|
167
|
+
finally:
|
|
168
|
+
txn.duration_ms = (time.perf_counter() - start) * 1000.0
|
|
169
|
+
_client.notify_transaction(txn)
|
|
170
|
+
|
|
171
|
+
|
|
95
172
|
def flush(timeout: Optional[float] = None) -> None:
|
|
96
173
|
_client.flush(timeout)
|
|
97
174
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Dict, List, Optional, Union
|
|
7
|
+
|
|
8
|
+
from .configuration import Configuration
|
|
9
|
+
|
|
10
|
+
#: The header the errorgap browser SDK sends with API calls.
|
|
11
|
+
TRACE_HEADER = "x-errorgap-trace"
|
|
12
|
+
|
|
13
|
+
_UUID = re.compile(r"\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z")
|
|
14
|
+
_STRING_LITERAL = re.compile(r"'(?:''|[^'])*'")
|
|
15
|
+
_NUMBER = re.compile(r"\b\d+(?:\.\d+)?\b")
|
|
16
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def browser_trace_id(header: Union[str, bytes, None]) -> Optional[str]:
|
|
20
|
+
"""The trace id in an ``x-errorgap-trace`` header value, lowercased, or
|
|
21
|
+
``None`` unless it is a well-formed UUID."""
|
|
22
|
+
if isinstance(header, bytes):
|
|
23
|
+
header = header.decode("latin-1")
|
|
24
|
+
if not isinstance(header, str):
|
|
25
|
+
return None
|
|
26
|
+
value = header.strip().lower()
|
|
27
|
+
return value if _UUID.match(value) else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_sql(sql: str) -> str:
|
|
31
|
+
"""Strip literals so query shapes aggregate: '…' and numbers become ?."""
|
|
32
|
+
sql = _STRING_LITERAL.sub("?", sql)
|
|
33
|
+
sql = _NUMBER.sub("?", sql)
|
|
34
|
+
return _WHITESPACE.sub(" ", sql).strip()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Span:
|
|
39
|
+
kind: str
|
|
40
|
+
duration_ms: float
|
|
41
|
+
sql: Optional[str] = None
|
|
42
|
+
file: Optional[str] = None
|
|
43
|
+
line: Optional[int] = None
|
|
44
|
+
function: Optional[str] = None
|
|
45
|
+
|
|
46
|
+
def to_payload(self) -> Dict[str, Any]:
|
|
47
|
+
payload: Dict[str, Any] = {"kind": self.kind, "duration_ms": self.duration_ms}
|
|
48
|
+
if self.sql is not None:
|
|
49
|
+
payload["sql"] = self.sql
|
|
50
|
+
if self.file is not None:
|
|
51
|
+
payload["file"] = self.file
|
|
52
|
+
if self.line is not None:
|
|
53
|
+
payload["line"] = self.line
|
|
54
|
+
if self.function is not None:
|
|
55
|
+
payload["fn_name"] = self.function
|
|
56
|
+
return payload
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SpanCollector:
|
|
60
|
+
"""Collects the spans recorded while a transaction or job is in flight."""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self._spans: List[Span] = []
|
|
64
|
+
|
|
65
|
+
def add(self, span: Span) -> None:
|
|
66
|
+
self._spans.append(span)
|
|
67
|
+
|
|
68
|
+
def database(
|
|
69
|
+
self,
|
|
70
|
+
sql: str,
|
|
71
|
+
duration_ms: float,
|
|
72
|
+
file: Optional[str] = None,
|
|
73
|
+
line: Optional[int] = None,
|
|
74
|
+
function: Optional[str] = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.add(Span("db", duration_ms, normalize_sql(sql), file, line, function))
|
|
77
|
+
|
|
78
|
+
def external(
|
|
79
|
+
self,
|
|
80
|
+
duration_ms: float,
|
|
81
|
+
file: Optional[str] = None,
|
|
82
|
+
line: Optional[int] = None,
|
|
83
|
+
function: Optional[str] = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.add(Span("http", duration_ms, None, file, line, function))
|
|
86
|
+
|
|
87
|
+
def snapshot(self) -> List[Span]:
|
|
88
|
+
return list(self._spans)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class Transaction:
|
|
93
|
+
"""An APM transaction: a web interaction (``kind="web"``) or a background
|
|
94
|
+
job (``kind="job"``)."""
|
|
95
|
+
|
|
96
|
+
kind: str = "web"
|
|
97
|
+
#: Links the errors raised during this transaction to it.
|
|
98
|
+
id: Optional[str] = None
|
|
99
|
+
#: The browser's ``x-errorgap-trace`` header (see :func:`browser_trace_id`).
|
|
100
|
+
trace_id: Optional[str] = None
|
|
101
|
+
method: Optional[str] = None
|
|
102
|
+
#: Normalized route template used for grouping, e.g. ``/orders/<int:id>``.
|
|
103
|
+
path: Optional[str] = None
|
|
104
|
+
#: Concrete path for one request, e.g. ``/orders/123``.
|
|
105
|
+
path_raw: Optional[str] = None
|
|
106
|
+
status_code: Optional[int] = None
|
|
107
|
+
duration_ms: float = 0.0
|
|
108
|
+
environment: Optional[str] = None
|
|
109
|
+
#: ISO-8601; defaults to now.
|
|
110
|
+
occurred_at: Optional[str] = None
|
|
111
|
+
spans: SpanCollector = field(default_factory=SpanCollector)
|
|
112
|
+
job_class: Optional[str] = None
|
|
113
|
+
queue: Optional[str] = None
|
|
114
|
+
|
|
115
|
+
def to_payload(self, configuration: Configuration) -> Dict[str, Any]:
|
|
116
|
+
payload: Dict[str, Any] = {
|
|
117
|
+
"kind": self.kind,
|
|
118
|
+
"duration_ms": self.duration_ms,
|
|
119
|
+
"environment": self.environment or configuration.environment,
|
|
120
|
+
"occurred_at": self.occurred_at or _now(),
|
|
121
|
+
"spans": [span.to_payload() for span in self.spans.snapshot()],
|
|
122
|
+
}
|
|
123
|
+
for key in ("id", "trace_id", "method", "path", "path_raw", "status_code", "job_class", "queue"):
|
|
124
|
+
value = getattr(self, key)
|
|
125
|
+
if value is not None:
|
|
126
|
+
payload[key] = value
|
|
127
|
+
return payload
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _now() -> str:
|
|
131
|
+
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
@@ -3,14 +3,17 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
5
|
import queue
|
|
6
|
+
import random
|
|
6
7
|
import threading
|
|
7
8
|
from dataclasses import dataclass
|
|
8
|
-
from typing import Any, Dict, Optional
|
|
9
|
+
from typing import Any, Dict, Optional, Tuple
|
|
9
10
|
from urllib import error as urlerror
|
|
10
11
|
from urllib import request as urlrequest
|
|
11
12
|
|
|
13
|
+
from .apm import Transaction
|
|
12
14
|
from .configuration import Configuration
|
|
13
15
|
from .notice import build_notice
|
|
16
|
+
from .transaction_context import current_transaction_id
|
|
14
17
|
from .version import VERSION
|
|
15
18
|
|
|
16
19
|
|
|
@@ -29,7 +32,7 @@ class DeliveryResult:
|
|
|
29
32
|
class Client:
|
|
30
33
|
def __init__(self, configuration: Configuration):
|
|
31
34
|
self._configuration = configuration
|
|
32
|
-
self._queue: "queue.Queue[Optional[Dict[str, Any]]]" = queue.Queue()
|
|
35
|
+
self._queue: "queue.Queue[Optional[Tuple[str, Dict[str, Any]]]]" = queue.Queue()
|
|
33
36
|
self._worker: Optional[threading.Thread] = None
|
|
34
37
|
self._lock = threading.Lock()
|
|
35
38
|
|
|
@@ -51,6 +54,9 @@ class Client:
|
|
|
51
54
|
) -> DeliveryResult:
|
|
52
55
|
try:
|
|
53
56
|
self._configuration.validate()
|
|
57
|
+
transaction_id = current_transaction_id()
|
|
58
|
+
if transaction_id and "transaction_id" not in (context or {}):
|
|
59
|
+
context = {**(context or {}), "transaction_id": transaction_id}
|
|
54
60
|
notice = build_notice(
|
|
55
61
|
exc,
|
|
56
62
|
self._configuration,
|
|
@@ -67,12 +73,37 @@ class Client:
|
|
|
67
73
|
return self.deliver(notice)
|
|
68
74
|
|
|
69
75
|
self._ensure_worker()
|
|
70
|
-
self._queue.put(notice)
|
|
76
|
+
self._queue.put(("notices", notice))
|
|
77
|
+
return DeliveryResult(queued=True, status=202)
|
|
78
|
+
|
|
79
|
+
def notify_transaction(self, transaction: Transaction, sync: bool = False) -> DeliveryResult:
|
|
80
|
+
"""Deliver an APM transaction. Dropped unless ``apm_enabled``, and
|
|
81
|
+
sampled by ``apm_sample_rate``."""
|
|
82
|
+
try:
|
|
83
|
+
self._configuration.validate()
|
|
84
|
+
if not self._configuration.apm_enabled:
|
|
85
|
+
return DeliveryResult(status=204)
|
|
86
|
+
rate = self._configuration.apm_sample_rate
|
|
87
|
+
if not (rate >= 1 or (rate > 0 and random.random() < rate)):
|
|
88
|
+
return DeliveryResult(status=204)
|
|
89
|
+
payload = transaction.to_payload(self._configuration)
|
|
90
|
+
except Exception as caught: # noqa: BLE001 — SDK must not raise
|
|
91
|
+
self._log(caught)
|
|
92
|
+
return DeliveryResult(error=caught)
|
|
93
|
+
|
|
94
|
+
if sync or not self._configuration.async_:
|
|
95
|
+
return self._post("transactions", payload)
|
|
96
|
+
|
|
97
|
+
self._ensure_worker()
|
|
98
|
+
self._queue.put(("transactions", payload))
|
|
71
99
|
return DeliveryResult(queued=True, status=202)
|
|
72
100
|
|
|
73
101
|
def deliver(self, notice: Dict[str, Any]) -> DeliveryResult:
|
|
74
|
-
|
|
75
|
-
|
|
102
|
+
return self._post("notices", notice)
|
|
103
|
+
|
|
104
|
+
def _post(self, resource: str, payload: Dict[str, Any]) -> DeliveryResult:
|
|
105
|
+
url = _project_url(self._configuration, resource)
|
|
106
|
+
body = json.dumps(payload).encode("utf-8")
|
|
76
107
|
headers = {
|
|
77
108
|
"Content-Type": "application/json",
|
|
78
109
|
"User-Agent": f"errorgap-python/{VERSION}",
|
|
@@ -130,7 +161,8 @@ class Client:
|
|
|
130
161
|
try:
|
|
131
162
|
if item is None:
|
|
132
163
|
return
|
|
133
|
-
|
|
164
|
+
resource, payload = item
|
|
165
|
+
self._post(resource, payload)
|
|
134
166
|
finally:
|
|
135
167
|
self._queue.task_done()
|
|
136
168
|
|
|
@@ -147,9 +179,9 @@ class Client:
|
|
|
147
179
|
pass
|
|
148
180
|
|
|
149
181
|
|
|
150
|
-
def
|
|
182
|
+
def _project_url(configuration: Configuration, resource: str) -> str:
|
|
151
183
|
base = configuration.endpoint.rstrip("/")
|
|
152
|
-
return f"{base}/api/projects/{configuration.project_slug}/
|
|
184
|
+
return f"{base}/api/projects/{configuration.project_slug}/{resource}"
|
|
153
185
|
|
|
154
186
|
|
|
155
187
|
def _wait_join(q: "queue.Queue[Any]", timeout: float) -> None:
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime, timezone
|
|
3
5
|
from typing import Any, Callable, Dict, Optional
|
|
4
6
|
|
|
5
7
|
from . import notify
|
|
8
|
+
from .apm import SpanCollector, Transaction, browser_trace_id
|
|
9
|
+
from .transaction_context import new_transaction_id, transaction_scope
|
|
10
|
+
|
|
11
|
+
_TRANSACTION_ATTR = "errorgap_transaction"
|
|
6
12
|
|
|
7
13
|
|
|
8
14
|
class ErrorgapMiddleware:
|
|
9
|
-
"""Django middleware that reports unhandled exceptions to Errorgap
|
|
15
|
+
"""Django middleware that reports unhandled exceptions to Errorgap and
|
|
16
|
+
times each request as an APM transaction (sent with ``apm_enabled``),
|
|
17
|
+
grouped by its URL pattern. Errors raised during the request carry its
|
|
18
|
+
transaction id, and the browser SDK's ``x-errorgap-trace`` header links
|
|
19
|
+
the browser's view of the call to it.
|
|
10
20
|
|
|
11
21
|
Add ``"errorgap.django.ErrorgapMiddleware"`` to ``MIDDLEWARE``. Place it
|
|
12
22
|
early so it sees exceptions raised by inner middleware too.
|
|
@@ -16,7 +26,30 @@ class ErrorgapMiddleware:
|
|
|
16
26
|
self.get_response = get_response
|
|
17
27
|
|
|
18
28
|
def __call__(self, request: Any) -> Any:
|
|
19
|
-
|
|
29
|
+
meta = getattr(request, "META", {}) or {}
|
|
30
|
+
txn = Transaction(
|
|
31
|
+
kind="web",
|
|
32
|
+
id=new_transaction_id(),
|
|
33
|
+
trace_id=browser_trace_id(meta.get("HTTP_X_ERRORGAP_TRACE")),
|
|
34
|
+
method=getattr(request, "method", None),
|
|
35
|
+
path_raw=getattr(request, "path", None) or "/",
|
|
36
|
+
occurred_at=datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
|
37
|
+
)
|
|
38
|
+
try:
|
|
39
|
+
setattr(request, _TRANSACTION_ATTR, txn)
|
|
40
|
+
except Exception: # noqa: BLE001
|
|
41
|
+
pass
|
|
42
|
+
start = time.perf_counter()
|
|
43
|
+
response = None
|
|
44
|
+
try:
|
|
45
|
+
with transaction_scope(txn.id or new_transaction_id()):
|
|
46
|
+
response = self.get_response(request)
|
|
47
|
+
return response
|
|
48
|
+
finally:
|
|
49
|
+
txn.status_code = getattr(response, "status_code", None) or 500
|
|
50
|
+
txn.duration_ms = (time.perf_counter() - start) * 1000.0
|
|
51
|
+
txn.path = _route(request) or txn.path_raw
|
|
52
|
+
_send_transaction(txn)
|
|
20
53
|
|
|
21
54
|
def process_exception(self, request: Any, exception: BaseException) -> Optional[Any]:
|
|
22
55
|
notify(
|
|
@@ -31,6 +64,28 @@ class ErrorgapMiddleware:
|
|
|
31
64
|
return None
|
|
32
65
|
|
|
33
66
|
|
|
67
|
+
def spans(request: Any) -> Optional[SpanCollector]:
|
|
68
|
+
"""The span collector for ``request``'s transaction, for recording DB and
|
|
69
|
+
outbound HTTP spans from views."""
|
|
70
|
+
txn = getattr(request, _TRANSACTION_ATTR, None)
|
|
71
|
+
return txn.spans if txn is not None else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _route(request: Any) -> Optional[str]:
|
|
75
|
+
"""The matched URL pattern (``/orders/<int:id>``), when one matched."""
|
|
76
|
+
match = getattr(request, "resolver_match", None)
|
|
77
|
+
route = getattr(match, "route", None) if match is not None else None
|
|
78
|
+
if not route:
|
|
79
|
+
return None
|
|
80
|
+
return route if route.startswith("/") else "/" + route
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _send_transaction(txn: Transaction) -> None:
|
|
84
|
+
import errorgap
|
|
85
|
+
|
|
86
|
+
errorgap.notify_transaction(txn)
|
|
87
|
+
|
|
88
|
+
|
|
34
89
|
def _context(request: Any) -> Dict[str, Any]:
|
|
35
90
|
try:
|
|
36
91
|
resolver = getattr(request, "resolver_match", None)
|