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.
Files changed (35) hide show
  1. errorgap-0.2.0/CHANGELOG.md +22 -0
  2. {errorgap-0.1.1 → errorgap-0.2.0}/PKG-INFO +57 -2
  3. {errorgap-0.1.1 → errorgap-0.2.0}/README.md +55 -0
  4. {errorgap-0.1.1 → errorgap-0.2.0}/pyproject.toml +1 -1
  5. {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/__init__.py +78 -1
  6. errorgap-0.2.0/src/errorgap/apm.py +131 -0
  7. {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/client.py +40 -8
  8. {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/flask.py +28 -3
  9. errorgap-0.2.0/src/errorgap/transaction_context.py +32 -0
  10. errorgap-0.2.0/src/errorgap/version.py +1 -0
  11. errorgap-0.2.0/src/errorgap/wsgi.py +109 -0
  12. errorgap-0.2.0/tests/test_apm.py +120 -0
  13. errorgap-0.2.0/tests/test_flask.py +74 -0
  14. errorgap-0.1.1/CHANGELOG.md +0 -9
  15. errorgap-0.1.1/src/errorgap/version.py +0 -1
  16. errorgap-0.1.1/tests/test_flask.py +0 -38
  17. {errorgap-0.1.1 → errorgap-0.2.0}/.github/workflows/ci.yml +0 -0
  18. {errorgap-0.1.1 → errorgap-0.2.0}/.github/workflows/release.yml +0 -0
  19. {errorgap-0.1.1 → errorgap-0.2.0}/.gitignore +0 -0
  20. {errorgap-0.1.1 → errorgap-0.2.0}/LICENSE +0 -0
  21. {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/backtrace.py +0 -0
  22. {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/configuration.py +0 -0
  23. {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/django.py +0 -0
  24. {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/fastapi.py +0 -0
  25. {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/filter.py +0 -0
  26. {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/handlers.py +0 -0
  27. {errorgap-0.1.1 → errorgap-0.2.0}/src/errorgap/notice.py +0 -0
  28. {errorgap-0.1.1 → errorgap-0.2.0}/tests/conftest.py +0 -0
  29. {errorgap-0.1.1 → errorgap-0.2.0}/tests/test_backtrace.py +0 -0
  30. {errorgap-0.1.1 → errorgap-0.2.0}/tests/test_client.py +0 -0
  31. {errorgap-0.1.1 → errorgap-0.2.0}/tests/test_configuration.py +0 -0
  32. {errorgap-0.1.1 → errorgap-0.2.0}/tests/test_django.py +0 -0
  33. {errorgap-0.1.1 → errorgap-0.2.0}/tests/test_fastapi.py +0 -0
  34. {errorgap-0.1.1 → errorgap-0.2.0}/tests/test_filter.py +0 -0
  35. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: errorgap
3
- Version: 0.1.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "errorgap"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Python notifier for Errorgap error tracking."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,15 +1,26 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Dict, Optional
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
- url = _notices_url(self._configuration)
75
- body = json.dumps(notice).encode("utf-8")
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
- self.deliver(item)
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 _notices_url(configuration: Configuration) -> str:
182
+ def _project_url(configuration: Configuration, resource: str) -> str:
151
183
  base = configuration.endpoint.rstrip("/")
152
- return f"{base}/api/projects/{configuration.project_slug}/notices"
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"]
@@ -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