errorgap 0.1.0__tar.gz → 0.2.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.2.0/CHANGELOG.md +22 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/PKG-INFO +60 -4
- {errorgap-0.1.0 → errorgap-0.2.0}/README.md +58 -2
- {errorgap-0.1.0 → errorgap-0.2.0}/pyproject.toml +1 -1
- {errorgap-0.1.0 → errorgap-0.2.0}/src/errorgap/__init__.py +78 -1
- errorgap-0.2.0/src/errorgap/apm.py +131 -0
- errorgap-0.2.0/src/errorgap/backtrace.py +79 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/src/errorgap/client.py +40 -8
- {errorgap-0.1.0 → errorgap-0.2.0}/src/errorgap/flask.py +28 -3
- errorgap-0.2.0/src/errorgap/transaction_context.py +32 -0
- errorgap-0.2.0/src/errorgap/version.py +1 -0
- errorgap-0.2.0/src/errorgap/wsgi.py +109 -0
- errorgap-0.2.0/tests/test_apm.py +120 -0
- errorgap-0.2.0/tests/test_backtrace.py +81 -0
- errorgap-0.2.0/tests/test_flask.py +74 -0
- errorgap-0.1.0/src/errorgap/backtrace.py +0 -45
- errorgap-0.1.0/src/errorgap/version.py +0 -1
- errorgap-0.1.0/tests/test_backtrace.py +0 -34
- errorgap-0.1.0/tests/test_flask.py +0 -38
- {errorgap-0.1.0 → errorgap-0.2.0}/.github/workflows/ci.yml +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/.github/workflows/release.yml +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/.gitignore +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/LICENSE +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/src/errorgap/configuration.py +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/src/errorgap/django.py +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/src/errorgap/fastapi.py +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/src/errorgap/filter.py +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/src/errorgap/handlers.py +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/src/errorgap/notice.py +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/tests/conftest.py +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/tests/test_client.py +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/tests/test_configuration.py +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/tests/test_django.py +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/tests/test_fastapi.py +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/tests/test_filter.py +0 -0
- {errorgap-0.1.0 → errorgap-0.2.0}/tests/test_notice.py +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-10-03
|
|
4
|
+
|
|
5
|
+
- APM: `track_transaction` / `track_job` context managers, `notify_transaction`,
|
|
6
|
+
and `SpanCollector` for DB and HTTP spans; enabled with `apm_enabled=True`,
|
|
7
|
+
sampled by `apm_sample_rate`.
|
|
8
|
+
- Errors reported during a transaction carry its id as
|
|
9
|
+
`context.transaction_id` (held in a `ContextVar`), linking each error to
|
|
10
|
+
the request or job that raised it.
|
|
11
|
+
- `errorgap.wsgi.ErrorgapMiddleware` times WSGI requests; `errorgap.flask.init_app`
|
|
12
|
+
installs it and groups requests by URL rule.
|
|
13
|
+
- Requests record the browser SDK's `x-errorgap-trace` header as the
|
|
14
|
+
transaction's `trace_id`, linking browser API calls to server requests.
|
|
15
|
+
|
|
16
|
+
## 0.1.1 — 2026-07-18
|
|
17
|
+
|
|
18
|
+
- Include bounded inline source excerpts for readable application and dependency
|
|
19
|
+
traceback frames so
|
|
20
|
+
Errorgap can render highlighted source without a repository integration.
|
|
21
|
+
- Order traceback frames innermost-first so frame 0 and group fingerprints use
|
|
22
|
+
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.2.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
|
|
@@ -37,7 +37,8 @@ Description-Content-Type: text/markdown
|
|
|
37
37
|
# errorgap
|
|
38
38
|
|
|
39
39
|
Python notifier for [Errorgap](https://errorgap.com). Captures exceptions,
|
|
40
|
-
normalizes tracebacks
|
|
40
|
+
normalizes tracebacks with bounded source excerpts for readable frames, and ships
|
|
41
|
+
notices to an Errorgap server. Single
|
|
41
42
|
package covers plain Python, Django, Flask, and FastAPI.
|
|
42
43
|
|
|
43
44
|
## Install
|
|
@@ -106,6 +107,59 @@ app = Flask(__name__)
|
|
|
106
107
|
init_app(app)
|
|
107
108
|
```
|
|
108
109
|
|
|
110
|
+
`init_app` also times each request as an APM transaction (sent with
|
|
111
|
+
`apm_enabled=True`) grouped by its URL rule (`/orders/<int:id>`), and errors
|
|
112
|
+
raised during the request carry its transaction id. Record spans from a view
|
|
113
|
+
with `errorgap.flask.spans()`:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from errorgap.flask import spans
|
|
117
|
+
|
|
118
|
+
@app.route("/orders/<int:order_id>")
|
|
119
|
+
def order(order_id):
|
|
120
|
+
started = time.perf_counter()
|
|
121
|
+
row = db.execute("SELECT * FROM orders WHERE id = %s", (order_id,)).fetchone()
|
|
122
|
+
spans().database("SELECT * FROM orders WHERE id = %s", (time.perf_counter() - started) * 1000)
|
|
123
|
+
return row
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## WSGI
|
|
127
|
+
|
|
128
|
+
Any WSGI app gets the same request transactions from
|
|
129
|
+
`errorgap.wsgi.ErrorgapMiddleware`. Set `environ["errorgap.route"]` to the
|
|
130
|
+
matched route template to group requests (otherwise the raw path is used).
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from errorgap.wsgi import ErrorgapMiddleware
|
|
134
|
+
|
|
135
|
+
application = ErrorgapMiddleware(application)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Transactions and jobs
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
with errorgap.track_transaction("GET", "/orders/{id}", "/orders/123") as txn:
|
|
142
|
+
txn.spans.database("SELECT * FROM orders WHERE id = 123", 4.2)
|
|
143
|
+
txn.status_code = 200
|
|
144
|
+
|
|
145
|
+
with errorgap.track_job("ReceiptJob", queue="mailers") as txn:
|
|
146
|
+
send_receipt()
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Both time the block and deliver on exit even if it raises. Errors reported
|
|
150
|
+
inside carry the transaction id (`context.transaction_id`), so errorgap shows
|
|
151
|
+
the error a request raised on its trace. The id lives in a `ContextVar`, so
|
|
152
|
+
concurrent threads and asyncio tasks never share one;
|
|
153
|
+
`errorgap.current_transaction_id()` returns the id in effect.
|
|
154
|
+
|
|
155
|
+
## Browser trace links
|
|
156
|
+
|
|
157
|
+
When the errorgap browser SDK (`@errorgap/browser` 0.3+) is on the page, its
|
|
158
|
+
API calls send an `x-errorgap-trace` header. The WSGI middleware (and so
|
|
159
|
+
`init_app`) records it, and `track_transaction(..., trace_id=header)` accepts
|
|
160
|
+
it, so errorgap's browser Performance view links each call to the server
|
|
161
|
+
request that answered it. Malformed values are ignored.
|
|
162
|
+
|
|
109
163
|
## FastAPI
|
|
110
164
|
|
|
111
165
|
```python
|
|
@@ -125,10 +179,12 @@ app.add_middleware(ErrorgapMiddleware)
|
|
|
125
179
|
| `project_id` | `ERRORGAP_PROJECT_ID` | Optional, embedded in payload |
|
|
126
180
|
| `api_key` | `ERRORGAP_API_KEY` | Sent as `x-errorgap-project-key` |
|
|
127
181
|
| `environment` | `ERRORGAP_ENVIRONMENT`, `ENV`, or `development` | |
|
|
128
|
-
| `root_directory` | `os.getcwd()` |
|
|
182
|
+
| `root_directory` | `os.getcwd()` | Marks application frames; readable dependency frames also include bounded source excerpts |
|
|
129
183
|
| `async_` | `True` | Background-thread delivery |
|
|
130
184
|
| `logger` | `logging.getLogger("errorgap")` | Pass `None` to silence |
|
|
131
185
|
| `filter_keys` | `("password", "token", ...)` | Substring match, case-insensitive |
|
|
186
|
+
| `apm_enabled` | `False` | Send APM transactions |
|
|
187
|
+
| `apm_sample_rate` | `1.0` | Fraction of transactions sent (errors are unaffected) |
|
|
132
188
|
| `capture_globals` | `True` | Install `sys.excepthook` |
|
|
133
189
|
|
|
134
190
|
## Graceful shutdown
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# errorgap
|
|
2
2
|
|
|
3
3
|
Python notifier for [Errorgap](https://errorgap.com). Captures exceptions,
|
|
4
|
-
normalizes tracebacks
|
|
4
|
+
normalizes tracebacks with bounded source excerpts for readable frames, and ships
|
|
5
|
+
notices to an Errorgap server. Single
|
|
5
6
|
package covers plain Python, Django, Flask, and FastAPI.
|
|
6
7
|
|
|
7
8
|
## Install
|
|
@@ -70,6 +71,59 @@ app = Flask(__name__)
|
|
|
70
71
|
init_app(app)
|
|
71
72
|
```
|
|
72
73
|
|
|
74
|
+
`init_app` also times each request as an APM transaction (sent with
|
|
75
|
+
`apm_enabled=True`) grouped by its URL rule (`/orders/<int:id>`), and errors
|
|
76
|
+
raised during the request carry its transaction id. Record spans from a view
|
|
77
|
+
with `errorgap.flask.spans()`:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from errorgap.flask import spans
|
|
81
|
+
|
|
82
|
+
@app.route("/orders/<int:order_id>")
|
|
83
|
+
def order(order_id):
|
|
84
|
+
started = time.perf_counter()
|
|
85
|
+
row = db.execute("SELECT * FROM orders WHERE id = %s", (order_id,)).fetchone()
|
|
86
|
+
spans().database("SELECT * FROM orders WHERE id = %s", (time.perf_counter() - started) * 1000)
|
|
87
|
+
return row
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## WSGI
|
|
91
|
+
|
|
92
|
+
Any WSGI app gets the same request transactions from
|
|
93
|
+
`errorgap.wsgi.ErrorgapMiddleware`. Set `environ["errorgap.route"]` to the
|
|
94
|
+
matched route template to group requests (otherwise the raw path is used).
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from errorgap.wsgi import ErrorgapMiddleware
|
|
98
|
+
|
|
99
|
+
application = ErrorgapMiddleware(application)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Transactions and jobs
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
with errorgap.track_transaction("GET", "/orders/{id}", "/orders/123") as txn:
|
|
106
|
+
txn.spans.database("SELECT * FROM orders WHERE id = 123", 4.2)
|
|
107
|
+
txn.status_code = 200
|
|
108
|
+
|
|
109
|
+
with errorgap.track_job("ReceiptJob", queue="mailers") as txn:
|
|
110
|
+
send_receipt()
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Both time the block and deliver on exit even if it raises. Errors reported
|
|
114
|
+
inside carry the transaction id (`context.transaction_id`), so errorgap shows
|
|
115
|
+
the error a request raised on its trace. The id lives in a `ContextVar`, so
|
|
116
|
+
concurrent threads and asyncio tasks never share one;
|
|
117
|
+
`errorgap.current_transaction_id()` returns the id in effect.
|
|
118
|
+
|
|
119
|
+
## Browser trace links
|
|
120
|
+
|
|
121
|
+
When the errorgap browser SDK (`@errorgap/browser` 0.3+) is on the page, its
|
|
122
|
+
API calls send an `x-errorgap-trace` header. The WSGI middleware (and so
|
|
123
|
+
`init_app`) records it, and `track_transaction(..., trace_id=header)` accepts
|
|
124
|
+
it, so errorgap's browser Performance view links each call to the server
|
|
125
|
+
request that answered it. Malformed values are ignored.
|
|
126
|
+
|
|
73
127
|
## FastAPI
|
|
74
128
|
|
|
75
129
|
```python
|
|
@@ -89,10 +143,12 @@ app.add_middleware(ErrorgapMiddleware)
|
|
|
89
143
|
| `project_id` | `ERRORGAP_PROJECT_ID` | Optional, embedded in payload |
|
|
90
144
|
| `api_key` | `ERRORGAP_API_KEY` | Sent as `x-errorgap-project-key` |
|
|
91
145
|
| `environment` | `ERRORGAP_ENVIRONMENT`, `ENV`, or `development` | |
|
|
92
|
-
| `root_directory` | `os.getcwd()` |
|
|
146
|
+
| `root_directory` | `os.getcwd()` | Marks application frames; readable dependency frames also include bounded source excerpts |
|
|
93
147
|
| `async_` | `True` | Background-thread delivery |
|
|
94
148
|
| `logger` | `logging.getLogger("errorgap")` | Pass `None` to silence |
|
|
95
149
|
| `filter_keys` | `("password", "token", ...)` | Substring match, case-insensitive |
|
|
150
|
+
| `apm_enabled` | `False` | Send APM transactions |
|
|
151
|
+
| `apm_sample_rate` | `1.0` | Fraction of transactions sent (errors are unaffected) |
|
|
96
152
|
| `capture_globals` | `True` | Install `sys.excepthook` |
|
|
97
153
|
|
|
98
154
|
## 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")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import traceback
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
SOURCE_CONTEXT_RADIUS = 6
|
|
8
|
+
MAX_SOURCE_LINE_CHARS = 400
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_backtrace(exc: BaseException, root_directory: str) -> List[Dict[str, Any]]:
|
|
12
|
+
frames: List[Dict[str, Any]] = []
|
|
13
|
+
# traceback.extract_tb returns oldest -> newest. Errorgap frame index 0 is
|
|
14
|
+
# the innermost frame where the exception was raised.
|
|
15
|
+
tb_frames = reversed(traceback.extract_tb(exc.__traceback__))
|
|
16
|
+
|
|
17
|
+
for index, frame in enumerate(tb_frames):
|
|
18
|
+
file = frame.filename
|
|
19
|
+
in_app = _is_in_app(file, root_directory)
|
|
20
|
+
normalized: Dict[str, Any] = {
|
|
21
|
+
"file": _relative_file(file, root_directory),
|
|
22
|
+
"line": frame.lineno,
|
|
23
|
+
"function": frame.name,
|
|
24
|
+
"in_app": in_app,
|
|
25
|
+
"index": index,
|
|
26
|
+
}
|
|
27
|
+
source = _source_excerpt(file, frame.lineno)
|
|
28
|
+
if source is not None:
|
|
29
|
+
normalized["source"] = source
|
|
30
|
+
frames.append(normalized)
|
|
31
|
+
|
|
32
|
+
return frames
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _relative_file(file: Optional[str], root: str) -> Optional[str]:
|
|
36
|
+
if not file:
|
|
37
|
+
return file
|
|
38
|
+
if not root:
|
|
39
|
+
return file
|
|
40
|
+
normalized = root if root.endswith(os.sep) else root + os.sep
|
|
41
|
+
if file.startswith(normalized):
|
|
42
|
+
return file[len(normalized):]
|
|
43
|
+
return file
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_in_app(file: Optional[str], root: str) -> bool:
|
|
47
|
+
if not file or not root:
|
|
48
|
+
return False
|
|
49
|
+
if "site-packages" in file:
|
|
50
|
+
return False
|
|
51
|
+
if file.startswith("<"):
|
|
52
|
+
return False
|
|
53
|
+
return file.startswith(root)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _source_excerpt(file: str, line: int) -> Optional[Dict[str, Any]]:
|
|
57
|
+
if line < 1:
|
|
58
|
+
return None
|
|
59
|
+
start_line = max(1, line - SOURCE_CONTEXT_RADIUS)
|
|
60
|
+
end_line = line + SOURCE_CONTEXT_RADIUS
|
|
61
|
+
lines: List[str] = []
|
|
62
|
+
target_found = False
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
with open(file, encoding="utf-8", errors="replace") as source_file:
|
|
66
|
+
for number, text in enumerate(source_file, start=1):
|
|
67
|
+
if number < start_line:
|
|
68
|
+
continue
|
|
69
|
+
if number > end_line:
|
|
70
|
+
break
|
|
71
|
+
lines.append(text.rstrip("\r\n")[:MAX_SOURCE_LINE_CHARS])
|
|
72
|
+
if number == line:
|
|
73
|
+
target_found = True
|
|
74
|
+
except OSError:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
if not lines or not target_found:
|
|
78
|
+
return None
|
|
79
|
+
return {"start_line": start_line, "lines": lines}
|
|
@@ -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,21 +1,35 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Any, Dict
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
4
|
|
|
5
5
|
from . import notify
|
|
6
|
+
from .apm import SpanCollector
|
|
7
|
+
from .wsgi import ROUTE_KEY, TRANSACTION_KEY, ErrorgapMiddleware
|
|
6
8
|
|
|
7
9
|
|
|
8
10
|
def init_app(app: Any) -> None:
|
|
9
11
|
"""Register Errorgap with a Flask application.
|
|
10
12
|
|
|
11
13
|
Subscribes to Flask's ``got_request_exception`` signal so all unhandled
|
|
12
|
-
exceptions raised inside a request are reported
|
|
14
|
+
exceptions raised inside a request are reported, and wraps the app in
|
|
15
|
+
:class:`~errorgap.wsgi.ErrorgapMiddleware` so each request is an APM
|
|
16
|
+
transaction (sent with ``apm_enabled``) grouped by its URL rule, and errors
|
|
17
|
+
carry its transaction id.
|
|
13
18
|
"""
|
|
14
19
|
try:
|
|
15
|
-
from flask import got_request_exception
|
|
20
|
+
from flask import got_request_exception, request
|
|
16
21
|
except ImportError as exc: # pragma: no cover
|
|
17
22
|
raise RuntimeError("Flask is required to use errorgap.flask.init_app") from exc
|
|
18
23
|
|
|
24
|
+
if not isinstance(app.wsgi_app, ErrorgapMiddleware):
|
|
25
|
+
app.wsgi_app = ErrorgapMiddleware(app.wsgi_app)
|
|
26
|
+
|
|
27
|
+
@app.before_request
|
|
28
|
+
def _errorgap_route() -> None:
|
|
29
|
+
rule = getattr(request, "url_rule", None)
|
|
30
|
+
if rule is not None:
|
|
31
|
+
request.environ[ROUTE_KEY] = rule.rule
|
|
32
|
+
|
|
19
33
|
def _on_exception(sender: Any, exception: BaseException, **_: Any) -> None:
|
|
20
34
|
try:
|
|
21
35
|
from flask import request
|
|
@@ -38,6 +52,17 @@ def init_app(app: Any) -> None:
|
|
|
38
52
|
got_request_exception.connect(_on_exception, app, weak=False)
|
|
39
53
|
|
|
40
54
|
|
|
55
|
+
def spans() -> Optional[SpanCollector]:
|
|
56
|
+
"""The span collector for the current request's transaction, for
|
|
57
|
+
recording DB and outbound HTTP spans from views."""
|
|
58
|
+
from flask import has_request_context, request
|
|
59
|
+
|
|
60
|
+
if not has_request_context():
|
|
61
|
+
return None
|
|
62
|
+
txn = request.environ.get(TRANSACTION_KEY)
|
|
63
|
+
return txn.spans if txn is not None else None
|
|
64
|
+
|
|
65
|
+
|
|
41
66
|
def _context(request: Any) -> Dict[str, Any]:
|
|
42
67
|
return {
|
|
43
68
|
"url": getattr(request, "url", None),
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import uuid
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from typing import Iterator, Optional
|
|
7
|
+
|
|
8
|
+
# The APM transaction the current thread or task is running in, so errors
|
|
9
|
+
# reported during it carry its id as ``context.transaction_id`` and errorgap
|
|
10
|
+
# links each error to the request or job that raised it. A ContextVar follows
|
|
11
|
+
# awaits and never leaks into a concurrent request or thread.
|
|
12
|
+
_current: ContextVar[Optional[str]] = ContextVar("errorgap_transaction_id", default=None)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def current_transaction_id() -> Optional[str]:
|
|
16
|
+
"""The id of the transaction running now, if any."""
|
|
17
|
+
return _current.get()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def new_transaction_id() -> str:
|
|
21
|
+
"""A new random transaction id (a UUID)."""
|
|
22
|
+
return str(uuid.uuid4())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@contextlib.contextmanager
|
|
26
|
+
def transaction_scope(transaction_id: str) -> Iterator[str]:
|
|
27
|
+
"""Make ``transaction_id`` current for the ``with`` block."""
|
|
28
|
+
token = _current.set(transaction_id)
|
|
29
|
+
try:
|
|
30
|
+
yield transaction_id
|
|
31
|
+
finally:
|
|
32
|
+
_current.reset(token)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "0.2.0"
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Callable, Iterable, Iterator, Optional
|
|
6
|
+
|
|
7
|
+
from .apm import Transaction, browser_trace_id
|
|
8
|
+
from .transaction_context import _current, new_transaction_id
|
|
9
|
+
|
|
10
|
+
#: WSGI environ key a framework integration sets to the matched route
|
|
11
|
+
#: template (for example Flask's ``/orders/<int:id>``), used to group requests.
|
|
12
|
+
ROUTE_KEY = "errorgap.route"
|
|
13
|
+
#: WSGI environ key holding the request's :class:`Transaction`, for recording
|
|
14
|
+
#: spans from views: ``environ["errorgap.transaction"].spans.database(...)``.
|
|
15
|
+
TRANSACTION_KEY = "errorgap.transaction"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ErrorgapMiddleware:
|
|
19
|
+
"""WSGI middleware that times each request as an APM transaction.
|
|
20
|
+
|
|
21
|
+
Errors reported while the request runs carry its transaction id, and the
|
|
22
|
+
errorgap browser SDK's ``x-errorgap-trace`` header links the browser's
|
|
23
|
+
view of the call to it. Transactions are sent only with ``apm_enabled``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, app: Callable[..., Iterable[bytes]]) -> None:
|
|
27
|
+
self.app = app
|
|
28
|
+
|
|
29
|
+
def __call__(self, environ: dict, start_response: Callable[..., Any]) -> Iterable[bytes]:
|
|
30
|
+
txn = Transaction(
|
|
31
|
+
kind="web",
|
|
32
|
+
id=new_transaction_id(),
|
|
33
|
+
trace_id=browser_trace_id(environ.get("HTTP_X_ERRORGAP_TRACE")),
|
|
34
|
+
method=environ.get("REQUEST_METHOD"),
|
|
35
|
+
path_raw=environ.get("PATH_INFO") or "/",
|
|
36
|
+
occurred_at=datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
|
37
|
+
)
|
|
38
|
+
environ[TRANSACTION_KEY] = txn
|
|
39
|
+
start = time.perf_counter()
|
|
40
|
+
|
|
41
|
+
def _start_response(status: str, headers: Any, exc_info: Any = None) -> Any:
|
|
42
|
+
try:
|
|
43
|
+
txn.status_code = int(status.split(" ", 1)[0])
|
|
44
|
+
except (ValueError, IndexError):
|
|
45
|
+
pass
|
|
46
|
+
return start_response(status, headers, exc_info) if exc_info else start_response(status, headers)
|
|
47
|
+
|
|
48
|
+
token = _current.set(txn.id)
|
|
49
|
+
try:
|
|
50
|
+
body = self.app(environ, _start_response)
|
|
51
|
+
except BaseException:
|
|
52
|
+
txn.status_code = 500
|
|
53
|
+
_current.reset(token)
|
|
54
|
+
_finish(txn, environ, start)
|
|
55
|
+
raise
|
|
56
|
+
_current.reset(token)
|
|
57
|
+
return _ClosingIterator(body, txn, environ, start)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _ClosingIterator:
|
|
61
|
+
"""Keeps the transaction current while the response body is produced, and
|
|
62
|
+
records it once the server closes the response."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, body: Iterable[bytes], txn: Transaction, environ: dict, start: float) -> None:
|
|
65
|
+
self._body = body
|
|
66
|
+
self._iter: Optional[Iterator[bytes]] = None
|
|
67
|
+
self._txn = txn
|
|
68
|
+
self._environ = environ
|
|
69
|
+
self._start = start
|
|
70
|
+
self._closed = False
|
|
71
|
+
|
|
72
|
+
def __iter__(self) -> "_ClosingIterator":
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def __next__(self) -> bytes:
|
|
76
|
+
if self._iter is None:
|
|
77
|
+
self._iter = iter(self._body)
|
|
78
|
+
token = _current.set(self._txn.id)
|
|
79
|
+
try:
|
|
80
|
+
return next(self._iter)
|
|
81
|
+
except StopIteration:
|
|
82
|
+
raise
|
|
83
|
+
except BaseException:
|
|
84
|
+
self._txn.status_code = 500
|
|
85
|
+
raise
|
|
86
|
+
finally:
|
|
87
|
+
_current.reset(token)
|
|
88
|
+
|
|
89
|
+
def close(self) -> None:
|
|
90
|
+
if self._closed:
|
|
91
|
+
return
|
|
92
|
+
self._closed = True
|
|
93
|
+
try:
|
|
94
|
+
close = getattr(self._body, "close", None)
|
|
95
|
+
if close is not None:
|
|
96
|
+
close()
|
|
97
|
+
finally:
|
|
98
|
+
_finish(self._txn, self._environ, self._start)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _finish(txn: Transaction, environ: dict, start: float) -> None:
|
|
102
|
+
import errorgap
|
|
103
|
+
|
|
104
|
+
txn.duration_ms = (time.perf_counter() - start) * 1000.0
|
|
105
|
+
txn.path = environ.get(ROUTE_KEY) or txn.path_raw
|
|
106
|
+
errorgap.notify_transaction(txn)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = ["ErrorgapMiddleware", "ROUTE_KEY", "TRANSACTION_KEY"]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import errorgap
|
|
8
|
+
from errorgap import browser_trace_id, current_transaction_id
|
|
9
|
+
from errorgap.wsgi import ROUTE_KEY, ErrorgapMiddleware
|
|
10
|
+
|
|
11
|
+
UUID = "0192f3c4-7a1b-4c2d-9e3f-0123456789ab"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def apm(ingestor, reset_errorgap):
|
|
16
|
+
errorgap.init(
|
|
17
|
+
endpoint=ingestor.endpoint,
|
|
18
|
+
project_slug="demo",
|
|
19
|
+
async_=False,
|
|
20
|
+
capture_globals=False,
|
|
21
|
+
apm_enabled=True,
|
|
22
|
+
)
|
|
23
|
+
return ingestor
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _by_resource(ingestor, resource):
|
|
27
|
+
return [r.body for r in ingestor.requests if r.path.endswith("/" + resource)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_browser_trace_id_accepts_only_uuids():
|
|
31
|
+
assert browser_trace_id(" 0192F3C4-7A1B-4C2D-9E3F-0123456789AB ") == UUID
|
|
32
|
+
assert browser_trace_id(UUID.encode()) == UUID
|
|
33
|
+
assert browser_trace_id("not-a-uuid") is None
|
|
34
|
+
assert browser_trace_id(UUID + "0") is None
|
|
35
|
+
assert browser_trace_id(None) is None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_track_job_links_errors_and_records_spans(apm):
|
|
39
|
+
with errorgap.track_job("ReceiptJob", queue="mailers") as txn:
|
|
40
|
+
txn.spans.database("SELECT * FROM receipts WHERE id = 7", 3.5)
|
|
41
|
+
errorgap.notify(RuntimeError("smtp down"))
|
|
42
|
+
seen = current_transaction_id()
|
|
43
|
+
assert current_transaction_id() is None
|
|
44
|
+
|
|
45
|
+
[job] = _by_resource(apm, "transactions")
|
|
46
|
+
assert job["kind"] == "job"
|
|
47
|
+
assert job["job_class"] == "ReceiptJob"
|
|
48
|
+
assert job["queue"] == "mailers"
|
|
49
|
+
assert job["id"] == seen
|
|
50
|
+
assert job["spans"][0]["sql"] == "SELECT * FROM receipts WHERE id = ?"
|
|
51
|
+
[notice] = _by_resource(apm, "notices")
|
|
52
|
+
assert notice["context"]["transaction_id"] == seen
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_track_transaction_records_failure_and_trace(apm):
|
|
56
|
+
with pytest.raises(ValueError):
|
|
57
|
+
with errorgap.track_transaction("GET", "/orders/{id}", "/orders/7", trace_id=UUID.upper()):
|
|
58
|
+
raise ValueError("boom")
|
|
59
|
+
[txn] = _by_resource(apm, "transactions")
|
|
60
|
+
assert txn["status_code"] == 500
|
|
61
|
+
assert txn["trace_id"] == UUID
|
|
62
|
+
assert txn["id"] != txn["trace_id"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_transactions_are_dropped_unless_apm_is_enabled(ingestor, reset_errorgap):
|
|
66
|
+
errorgap.init(endpoint=ingestor.endpoint, project_slug="demo", async_=False, capture_globals=False)
|
|
67
|
+
with errorgap.track_transaction("GET", "/", "/"):
|
|
68
|
+
pass
|
|
69
|
+
assert _by_resource(ingestor, "transactions") == []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_threads_never_share_a_transaction_id(apm):
|
|
73
|
+
seen = {}
|
|
74
|
+
|
|
75
|
+
def work(n):
|
|
76
|
+
with errorgap.track_job("Job%d" % n):
|
|
77
|
+
seen[n] = current_transaction_id()
|
|
78
|
+
|
|
79
|
+
threads = [threading.Thread(target=work, args=(n,)) for n in range(3)]
|
|
80
|
+
for t in threads:
|
|
81
|
+
t.start()
|
|
82
|
+
for t in threads:
|
|
83
|
+
t.join()
|
|
84
|
+
assert len(set(seen.values())) == 3
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_wsgi_middleware_records_the_request(apm):
|
|
88
|
+
def app(environ, start_response):
|
|
89
|
+
environ[ROUTE_KEY] = "/orders/<id>"
|
|
90
|
+
errorgap.notify(RuntimeError("card declined"))
|
|
91
|
+
start_response("201 Created", [("Content-Type", "text/plain")])
|
|
92
|
+
return [b"ok"]
|
|
93
|
+
|
|
94
|
+
wrapped = ErrorgapMiddleware(app)
|
|
95
|
+
environ = {"REQUEST_METHOD": "POST", "PATH_INFO": "/orders/7", "HTTP_X_ERRORGAP_TRACE": UUID}
|
|
96
|
+
body = wrapped(environ, lambda status, headers: None)
|
|
97
|
+
assert b"".join(body) == b"ok"
|
|
98
|
+
body.close()
|
|
99
|
+
|
|
100
|
+
[txn] = _by_resource(apm, "transactions")
|
|
101
|
+
assert txn["method"] == "POST"
|
|
102
|
+
assert txn["path"] == "/orders/<id>"
|
|
103
|
+
assert txn["path_raw"] == "/orders/7"
|
|
104
|
+
assert txn["status_code"] == 201
|
|
105
|
+
assert txn["trace_id"] == UUID
|
|
106
|
+
[notice] = _by_resource(apm, "notices")
|
|
107
|
+
assert notice["context"]["transaction_id"] == txn["id"]
|
|
108
|
+
assert current_transaction_id() is None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_wsgi_middleware_records_a_raising_app(apm):
|
|
112
|
+
def app(environ, start_response):
|
|
113
|
+
raise RuntimeError("kaboom")
|
|
114
|
+
|
|
115
|
+
with pytest.raises(RuntimeError):
|
|
116
|
+
ErrorgapMiddleware(app)({"REQUEST_METHOD": "GET", "PATH_INFO": "/x", "HTTP_X_ERRORGAP_TRACE": "nope"}, None)
|
|
117
|
+
[txn] = _by_resource(apm, "transactions")
|
|
118
|
+
assert txn["status_code"] == 500
|
|
119
|
+
assert txn["path"] == "/x"
|
|
120
|
+
assert "trace_id" not in txn
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from errorgap.backtrace import (
|
|
6
|
+
MAX_SOURCE_LINE_CHARS,
|
|
7
|
+
SOURCE_CONTEXT_RADIUS,
|
|
8
|
+
_source_excerpt,
|
|
9
|
+
parse_backtrace,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _raise_and_capture() -> Exception:
|
|
14
|
+
try:
|
|
15
|
+
raise ValueError("boom")
|
|
16
|
+
except Exception as exc:
|
|
17
|
+
return exc
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_parses_frames():
|
|
21
|
+
exc = _raise_and_capture()
|
|
22
|
+
frames = parse_backtrace(exc, os.getcwd())
|
|
23
|
+
assert len(frames) >= 1
|
|
24
|
+
top = frames[0]
|
|
25
|
+
assert top["function"] == "_raise_and_capture"
|
|
26
|
+
assert top["line"] > 0
|
|
27
|
+
assert "test_backtrace" in (top["file"] or "")
|
|
28
|
+
assert top["index"] == 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_in_app_marks_files_under_root():
|
|
32
|
+
exc = _raise_and_capture()
|
|
33
|
+
frames = parse_backtrace(exc, os.getcwd())
|
|
34
|
+
assert frames[0]["in_app"] is True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_empty_for_exception_without_traceback():
|
|
38
|
+
exc = ValueError("no tb")
|
|
39
|
+
assert parse_backtrace(exc, os.getcwd()) == []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _inner_failure() -> None:
|
|
43
|
+
raise RuntimeError("inner boom")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _outer_failure() -> None:
|
|
47
|
+
_inner_failure()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_innermost_frame_is_first_and_includes_source_context():
|
|
51
|
+
try:
|
|
52
|
+
_outer_failure()
|
|
53
|
+
except RuntimeError as exc:
|
|
54
|
+
frames = parse_backtrace(exc, os.getcwd())
|
|
55
|
+
|
|
56
|
+
assert frames[0]["function"] == "_inner_failure"
|
|
57
|
+
assert frames[1]["function"] == "_outer_failure"
|
|
58
|
+
source = frames[0]["source"]
|
|
59
|
+
target_offset = frames[0]["line"] - source["start_line"]
|
|
60
|
+
assert 'raise RuntimeError("inner boom")' in source["lines"][target_offset]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_attaches_source_to_readable_vendor_frames():
|
|
64
|
+
exc = _raise_and_capture()
|
|
65
|
+
frames = parse_backtrace(exc, "/path/that/does/not/contain/tests")
|
|
66
|
+
assert frames[0]["in_app"] is False
|
|
67
|
+
source = frames[0]["source"]
|
|
68
|
+
target_offset = frames[0]["line"] - source["start_line"]
|
|
69
|
+
assert 'raise ValueError("boom")' in source["lines"][target_offset]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_source_excerpt_is_bounded(tmp_path):
|
|
73
|
+
source_file = tmp_path / "app.py"
|
|
74
|
+
source_file.write_text("\n".join(["x" * 500] * 30), encoding="utf-8")
|
|
75
|
+
|
|
76
|
+
source = _source_excerpt(str(source_file), 15)
|
|
77
|
+
|
|
78
|
+
assert source is not None
|
|
79
|
+
assert source["start_line"] == 15 - SOURCE_CONTEXT_RADIUS
|
|
80
|
+
assert len(source["lines"]) == SOURCE_CONTEXT_RADIUS * 2 + 1
|
|
81
|
+
assert all(len(line) == MAX_SOURCE_LINE_CHARS for line in source["lines"])
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
flask = pytest.importorskip("flask")
|
|
6
|
+
|
|
7
|
+
from flask import Flask
|
|
8
|
+
|
|
9
|
+
import errorgap
|
|
10
|
+
from errorgap.flask import init_app
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_flask_signal_reports_exception(ingestor, reset_errorgap):
|
|
14
|
+
errorgap.init(
|
|
15
|
+
endpoint=ingestor.endpoint,
|
|
16
|
+
project_slug="demo",
|
|
17
|
+
api_key="flk_test",
|
|
18
|
+
async_=False,
|
|
19
|
+
capture_globals=False,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app = Flask(__name__)
|
|
23
|
+
init_app(app)
|
|
24
|
+
|
|
25
|
+
@app.route("/boom")
|
|
26
|
+
def boom():
|
|
27
|
+
raise RuntimeError("flask-boom")
|
|
28
|
+
|
|
29
|
+
client = app.test_client()
|
|
30
|
+
response = client.get("/boom?x=1")
|
|
31
|
+
assert response.status_code == 500
|
|
32
|
+
|
|
33
|
+
errorgap.flush(timeout=5)
|
|
34
|
+
assert len(ingestor.requests) == 1
|
|
35
|
+
body = ingestor.requests[0].body
|
|
36
|
+
assert body["errors"][0]["type"] == "RuntimeError"
|
|
37
|
+
assert body["errors"][0]["message"] == "flask-boom"
|
|
38
|
+
assert body["context"]["action"] == "GET"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_flask_requests_are_transactions_linked_to_their_errors(ingestor, reset_errorgap):
|
|
42
|
+
from errorgap.flask import spans
|
|
43
|
+
|
|
44
|
+
errorgap.init(
|
|
45
|
+
endpoint=ingestor.endpoint,
|
|
46
|
+
project_slug="demo",
|
|
47
|
+
async_=False,
|
|
48
|
+
capture_globals=False,
|
|
49
|
+
apm_enabled=True,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
app = Flask(__name__)
|
|
53
|
+
init_app(app)
|
|
54
|
+
|
|
55
|
+
@app.route("/orders/<int:order_id>")
|
|
56
|
+
def order(order_id):
|
|
57
|
+
spans().database("SELECT * FROM orders WHERE id = %d" % order_id, 1.5)
|
|
58
|
+
raise RuntimeError("flask-boom")
|
|
59
|
+
|
|
60
|
+
response = app.test_client().get(
|
|
61
|
+
"/orders/7", headers={"x-errorgap-trace": "0192F3C4-7A1B-4C2D-9E3F-0123456789AB"}
|
|
62
|
+
)
|
|
63
|
+
assert response.status_code == 500
|
|
64
|
+
response.close() # WSGI servers close the response; the transaction is recorded then
|
|
65
|
+
|
|
66
|
+
errorgap.flush(timeout=5)
|
|
67
|
+
[txn] = [r.body for r in ingestor.requests if r.path.endswith("/transactions")]
|
|
68
|
+
[notice] = [r.body for r in ingestor.requests if r.path.endswith("/notices")]
|
|
69
|
+
assert txn["path"] == "/orders/<int:order_id>"
|
|
70
|
+
assert txn["path_raw"] == "/orders/7"
|
|
71
|
+
assert txn["status_code"] == 500
|
|
72
|
+
assert txn["trace_id"] == "0192f3c4-7a1b-4c2d-9e3f-0123456789ab"
|
|
73
|
+
assert txn["spans"][0]["sql"] == "SELECT * FROM orders WHERE id = ?"
|
|
74
|
+
assert notice["context"]["transaction_id"] == txn["id"]
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import traceback
|
|
5
|
-
from typing import Any, Dict, List, Optional
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def parse_backtrace(exc: BaseException, root_directory: str) -> List[Dict[str, Any]]:
|
|
9
|
-
frames: List[Dict[str, Any]] = []
|
|
10
|
-
tb_frames = traceback.extract_tb(exc.__traceback__)
|
|
11
|
-
|
|
12
|
-
for index, frame in enumerate(tb_frames):
|
|
13
|
-
file = frame.filename
|
|
14
|
-
frames.append(
|
|
15
|
-
{
|
|
16
|
-
"file": _relative_file(file, root_directory),
|
|
17
|
-
"line": frame.lineno,
|
|
18
|
-
"function": frame.name,
|
|
19
|
-
"in_app": _is_in_app(file, root_directory),
|
|
20
|
-
"index": index,
|
|
21
|
-
}
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
return frames
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def _relative_file(file: Optional[str], root: str) -> Optional[str]:
|
|
28
|
-
if not file:
|
|
29
|
-
return file
|
|
30
|
-
if not root:
|
|
31
|
-
return file
|
|
32
|
-
normalized = root if root.endswith(os.sep) else root + os.sep
|
|
33
|
-
if file.startswith(normalized):
|
|
34
|
-
return file[len(normalized):]
|
|
35
|
-
return file
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _is_in_app(file: Optional[str], root: str) -> bool:
|
|
39
|
-
if not file or not root:
|
|
40
|
-
return False
|
|
41
|
-
if "site-packages" in file:
|
|
42
|
-
return False
|
|
43
|
-
if file.startswith("<"):
|
|
44
|
-
return False
|
|
45
|
-
return file.startswith(root)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
VERSION = "0.1.0"
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
|
|
5
|
-
from errorgap.backtrace import parse_backtrace
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def _raise_and_capture() -> Exception:
|
|
9
|
-
try:
|
|
10
|
-
raise ValueError("boom")
|
|
11
|
-
except Exception as exc:
|
|
12
|
-
return exc
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def test_parses_frames():
|
|
16
|
-
exc = _raise_and_capture()
|
|
17
|
-
frames = parse_backtrace(exc, os.getcwd())
|
|
18
|
-
assert len(frames) >= 1
|
|
19
|
-
top = frames[0]
|
|
20
|
-
assert top["function"] == "_raise_and_capture"
|
|
21
|
-
assert top["line"] > 0
|
|
22
|
-
assert "test_backtrace" in (top["file"] or "")
|
|
23
|
-
assert top["index"] == 0
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def test_in_app_marks_files_under_root():
|
|
27
|
-
exc = _raise_and_capture()
|
|
28
|
-
frames = parse_backtrace(exc, os.getcwd())
|
|
29
|
-
assert frames[0]["in_app"] is True
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def test_empty_for_exception_without_traceback():
|
|
33
|
-
exc = ValueError("no tb")
|
|
34
|
-
assert parse_backtrace(exc, os.getcwd()) == []
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
flask = pytest.importorskip("flask")
|
|
6
|
-
|
|
7
|
-
from flask import Flask
|
|
8
|
-
|
|
9
|
-
import errorgap
|
|
10
|
-
from errorgap.flask import init_app
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def test_flask_signal_reports_exception(ingestor, reset_errorgap):
|
|
14
|
-
errorgap.init(
|
|
15
|
-
endpoint=ingestor.endpoint,
|
|
16
|
-
project_slug="demo",
|
|
17
|
-
api_key="flk_test",
|
|
18
|
-
async_=False,
|
|
19
|
-
capture_globals=False,
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
app = Flask(__name__)
|
|
23
|
-
init_app(app)
|
|
24
|
-
|
|
25
|
-
@app.route("/boom")
|
|
26
|
-
def boom():
|
|
27
|
-
raise RuntimeError("flask-boom")
|
|
28
|
-
|
|
29
|
-
client = app.test_client()
|
|
30
|
-
response = client.get("/boom?x=1")
|
|
31
|
-
assert response.status_code == 500
|
|
32
|
-
|
|
33
|
-
errorgap.flush(timeout=5)
|
|
34
|
-
assert len(ingestor.requests) == 1
|
|
35
|
-
body = ingestor.requests[0].body
|
|
36
|
-
assert body["errors"][0]["type"] == "RuntimeError"
|
|
37
|
-
assert body["errors"][0]["message"] == "flask-boom"
|
|
38
|
-
assert body["context"]["action"] == "GET"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|