errorgap 0.1.1__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.1 → errorgap-0.2.0}/PKG-INFO +57 -2
- {errorgap-0.1.1 → errorgap-0.2.0}/README.md +55 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/pyproject.toml +1 -1
- {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/__init__.py +78 -1
- errorgap-0.2.0/src/errorgap/apm.py +131 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/client.py +40 -8
- {errorgap-0.1.1 → 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_flask.py +74 -0
- errorgap-0.1.1/CHANGELOG.md +0 -9
- errorgap-0.1.1/src/errorgap/version.py +0 -1
- errorgap-0.1.1/tests/test_flask.py +0 -38
- {errorgap-0.1.1 → errorgap-0.2.0}/.github/workflows/ci.yml +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/.github/workflows/release.yml +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/.gitignore +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/LICENSE +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/backtrace.py +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/configuration.py +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/django.py +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/fastapi.py +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/filter.py +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/handlers.py +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/notice.py +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/tests/conftest.py +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/tests/test_backtrace.py +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/tests/test_client.py +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/tests/test_configuration.py +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/tests/test_django.py +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/tests/test_fastapi.py +0 -0
- {errorgap-0.1.1 → errorgap-0.2.0}/tests/test_filter.py +0 -0
- {errorgap-0.1.1 → 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
|
|
@@ -107,6 +107,59 @@ app = Flask(__name__)
|
|
|
107
107
|
init_app(app)
|
|
108
108
|
```
|
|
109
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
|
+
|
|
110
163
|
## FastAPI
|
|
111
164
|
|
|
112
165
|
```python
|
|
@@ -130,6 +183,8 @@ app.add_middleware(ErrorgapMiddleware)
|
|
|
130
183
|
| `async_` | `True` | Background-thread delivery |
|
|
131
184
|
| `logger` | `logging.getLogger("errorgap")` | Pass `None` to silence |
|
|
132
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) |
|
|
133
188
|
| `capture_globals` | `True` | Install `sys.excepthook` |
|
|
134
189
|
|
|
135
190
|
## Graceful shutdown
|
|
@@ -71,6 +71,59 @@ app = Flask(__name__)
|
|
|
71
71
|
init_app(app)
|
|
72
72
|
```
|
|
73
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
|
+
|
|
74
127
|
## FastAPI
|
|
75
128
|
|
|
76
129
|
```python
|
|
@@ -94,6 +147,8 @@ app.add_middleware(ErrorgapMiddleware)
|
|
|
94
147
|
| `async_` | `True` | Background-thread delivery |
|
|
95
148
|
| `logger` | `logging.getLogger("errorgap")` | Pass `None` to silence |
|
|
96
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) |
|
|
97
152
|
| `capture_globals` | `True` | Install `sys.excepthook` |
|
|
98
153
|
|
|
99
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")
|
|
@@ -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,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"]
|
errorgap-0.1.1/CHANGELOG.md
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.1.1 — 2026-07-18
|
|
4
|
-
|
|
5
|
-
- Include bounded inline source excerpts for readable application and dependency
|
|
6
|
-
traceback frames so
|
|
7
|
-
Errorgap can render highlighted source without a repository integration.
|
|
8
|
-
- Order traceback frames innermost-first so frame 0 and group fingerprints use
|
|
9
|
-
the actual exception site.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
VERSION = "0.1.1"
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|