errorgap 0.1.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. errorgap-0.3.0/CHANGELOG.md +30 -0
  2. {errorgap-0.1.1 → errorgap-0.3.0}/PKG-INFO +68 -2
  3. {errorgap-0.1.1 → errorgap-0.3.0}/README.md +66 -0
  4. {errorgap-0.1.1 → errorgap-0.3.0}/pyproject.toml +1 -1
  5. {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/__init__.py +78 -1
  6. errorgap-0.3.0/src/errorgap/apm.py +131 -0
  7. {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/client.py +40 -8
  8. {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/django.py +57 -2
  9. errorgap-0.3.0/src/errorgap/fastapi.py +120 -0
  10. {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/flask.py +28 -3
  11. errorgap-0.3.0/src/errorgap/transaction_context.py +32 -0
  12. errorgap-0.3.0/src/errorgap/version.py +1 -0
  13. errorgap-0.3.0/src/errorgap/wsgi.py +109 -0
  14. errorgap-0.3.0/tests/test_apm.py +120 -0
  15. errorgap-0.3.0/tests/test_django.py +111 -0
  16. errorgap-0.3.0/tests/test_fastapi.py +82 -0
  17. errorgap-0.3.0/tests/test_flask.py +74 -0
  18. errorgap-0.1.1/CHANGELOG.md +0 -9
  19. errorgap-0.1.1/src/errorgap/fastapi.py +0 -67
  20. errorgap-0.1.1/src/errorgap/version.py +0 -1
  21. errorgap-0.1.1/tests/test_django.py +0 -65
  22. errorgap-0.1.1/tests/test_fastapi.py +0 -39
  23. errorgap-0.1.1/tests/test_flask.py +0 -38
  24. {errorgap-0.1.1 → errorgap-0.3.0}/.github/workflows/ci.yml +0 -0
  25. {errorgap-0.1.1 → errorgap-0.3.0}/.github/workflows/release.yml +0 -0
  26. {errorgap-0.1.1 → errorgap-0.3.0}/.gitignore +0 -0
  27. {errorgap-0.1.1 → errorgap-0.3.0}/LICENSE +0 -0
  28. {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/backtrace.py +0 -0
  29. {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/configuration.py +0 -0
  30. {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/filter.py +0 -0
  31. {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/handlers.py +0 -0
  32. {errorgap-0.1.1 → errorgap-0.3.0}/src/errorgap/notice.py +0 -0
  33. {errorgap-0.1.1 → errorgap-0.3.0}/tests/conftest.py +0 -0
  34. {errorgap-0.1.1 → errorgap-0.3.0}/tests/test_backtrace.py +0 -0
  35. {errorgap-0.1.1 → errorgap-0.3.0}/tests/test_client.py +0 -0
  36. {errorgap-0.1.1 → errorgap-0.3.0}/tests/test_configuration.py +0 -0
  37. {errorgap-0.1.1 → errorgap-0.3.0}/tests/test_filter.py +0 -0
  38. {errorgap-0.1.1 → errorgap-0.3.0}/tests/test_notice.py +0 -0
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0 — 2026-10-04
4
+
5
+ - Django and FastAPI/Starlette middleware now time each request as an APM
6
+ transaction (with `apm_enabled`), grouped by URL pattern / route path, with
7
+ errors linked to their request and the browser's `x-errorgap-trace` header
8
+ recorded. `errorgap.django.spans(request)` / `errorgap.fastapi.spans(request)`
9
+ record spans.
10
+
11
+ ## 0.2.0 — 2026-10-03
12
+
13
+ - APM: `track_transaction` / `track_job` context managers, `notify_transaction`,
14
+ and `SpanCollector` for DB and HTTP spans; enabled with `apm_enabled=True`,
15
+ sampled by `apm_sample_rate`.
16
+ - Errors reported during a transaction carry its id as
17
+ `context.transaction_id` (held in a `ContextVar`), linking each error to
18
+ the request or job that raised it.
19
+ - `errorgap.wsgi.ErrorgapMiddleware` times WSGI requests; `errorgap.flask.init_app`
20
+ installs it and groups requests by URL rule.
21
+ - Requests record the browser SDK's `x-errorgap-trace` header as the
22
+ transaction's `trace_id`, linking browser API calls to server requests.
23
+
24
+ ## 0.1.1 — 2026-07-18
25
+
26
+ - Include bounded inline source excerpts for readable application and dependency
27
+ traceback frames so
28
+ Errorgap can render highlighted source without a repository integration.
29
+ - Order traceback frames innermost-first so frame 0 and group fingerprints use
30
+ the actual exception site.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: errorgap
3
- Version: 0.1.1
3
+ Version: 0.3.0
4
4
  Summary: Python notifier for Errorgap error tracking.
5
5
  Project-URL: Homepage, https://github.com/errorgaphq/errorgap-python
6
6
  Project-URL: Source, https://github.com/errorgaphq/errorgap-python
@@ -97,6 +97,11 @@ MIDDLEWARE = [
97
97
  ]
98
98
  ```
99
99
 
100
+ With `apm_enabled=True` each request is also an APM transaction grouped by
101
+ its URL pattern (`/orders/<int:order_id>`); errors raised during it carry its
102
+ transaction id, and the browser SDK's `x-errorgap-trace` header is recorded.
103
+ Record spans from a view with `errorgap.django.spans(request).database(sql, ms)`.
104
+
100
105
  ## Flask
101
106
 
102
107
  ```python
@@ -107,6 +112,59 @@ app = Flask(__name__)
107
112
  init_app(app)
108
113
  ```
109
114
 
115
+ `init_app` also times each request as an APM transaction (sent with
116
+ `apm_enabled=True`) grouped by its URL rule (`/orders/<int:id>`), and errors
117
+ raised during the request carry its transaction id. Record spans from a view
118
+ with `errorgap.flask.spans()`:
119
+
120
+ ```python
121
+ from errorgap.flask import spans
122
+
123
+ @app.route("/orders/<int:order_id>")
124
+ def order(order_id):
125
+ started = time.perf_counter()
126
+ row = db.execute("SELECT * FROM orders WHERE id = %s", (order_id,)).fetchone()
127
+ spans().database("SELECT * FROM orders WHERE id = %s", (time.perf_counter() - started) * 1000)
128
+ return row
129
+ ```
130
+
131
+ ## WSGI
132
+
133
+ Any WSGI app gets the same request transactions from
134
+ `errorgap.wsgi.ErrorgapMiddleware`. Set `environ["errorgap.route"]` to the
135
+ matched route template to group requests (otherwise the raw path is used).
136
+
137
+ ```python
138
+ from errorgap.wsgi import ErrorgapMiddleware
139
+
140
+ application = ErrorgapMiddleware(application)
141
+ ```
142
+
143
+ ## Transactions and jobs
144
+
145
+ ```python
146
+ with errorgap.track_transaction("GET", "/orders/{id}", "/orders/123") as txn:
147
+ txn.spans.database("SELECT * FROM orders WHERE id = 123", 4.2)
148
+ txn.status_code = 200
149
+
150
+ with errorgap.track_job("ReceiptJob", queue="mailers") as txn:
151
+ send_receipt()
152
+ ```
153
+
154
+ Both time the block and deliver on exit even if it raises. Errors reported
155
+ inside carry the transaction id (`context.transaction_id`), so errorgap shows
156
+ the error a request raised on its trace. The id lives in a `ContextVar`, so
157
+ concurrent threads and asyncio tasks never share one;
158
+ `errorgap.current_transaction_id()` returns the id in effect.
159
+
160
+ ## Browser trace links
161
+
162
+ When the errorgap browser SDK (`@errorgap/browser` 0.3+) is on the page, its
163
+ API calls send an `x-errorgap-trace` header. The WSGI, Django and FastAPI
164
+ middleware (and so Flask's `init_app`) record it, and `track_transaction(..., trace_id=header)` accepts
165
+ it, so errorgap's browser Performance view links each call to the server
166
+ request that answered it. Malformed values are ignored.
167
+
110
168
  ## FastAPI
111
169
 
112
170
  ```python
@@ -117,6 +175,12 @@ app = FastAPI()
117
175
  app.add_middleware(ErrorgapMiddleware)
118
176
  ```
119
177
 
178
+ With `apm_enabled=True` each request is also an APM transaction grouped by
179
+ its route path (`/orders/{order_id}`); errors raised during it carry its
180
+ transaction id, and the browser SDK's `x-errorgap-trace` header is recorded.
181
+ Record spans from an endpoint with `errorgap.fastapi.spans(request).database(sql, ms)`
182
+ (take `request: Request` as a parameter).
183
+
120
184
  ## Configuration reference
121
185
 
122
186
  | Argument | Default | Notes |
@@ -130,6 +194,8 @@ app.add_middleware(ErrorgapMiddleware)
130
194
  | `async_` | `True` | Background-thread delivery |
131
195
  | `logger` | `logging.getLogger("errorgap")` | Pass `None` to silence |
132
196
  | `filter_keys` | `("password", "token", ...)` | Substring match, case-insensitive |
197
+ | `apm_enabled` | `False` | Send APM transactions |
198
+ | `apm_sample_rate` | `1.0` | Fraction of transactions sent (errors are unaffected) |
133
199
  | `capture_globals` | `True` | Install `sys.excepthook` |
134
200
 
135
201
  ## Graceful shutdown
@@ -61,6 +61,11 @@ MIDDLEWARE = [
61
61
  ]
62
62
  ```
63
63
 
64
+ With `apm_enabled=True` each request is also an APM transaction grouped by
65
+ its URL pattern (`/orders/<int:order_id>`); errors raised during it carry its
66
+ transaction id, and the browser SDK's `x-errorgap-trace` header is recorded.
67
+ Record spans from a view with `errorgap.django.spans(request).database(sql, ms)`.
68
+
64
69
  ## Flask
65
70
 
66
71
  ```python
@@ -71,6 +76,59 @@ app = Flask(__name__)
71
76
  init_app(app)
72
77
  ```
73
78
 
79
+ `init_app` also times each request as an APM transaction (sent with
80
+ `apm_enabled=True`) grouped by its URL rule (`/orders/<int:id>`), and errors
81
+ raised during the request carry its transaction id. Record spans from a view
82
+ with `errorgap.flask.spans()`:
83
+
84
+ ```python
85
+ from errorgap.flask import spans
86
+
87
+ @app.route("/orders/<int:order_id>")
88
+ def order(order_id):
89
+ started = time.perf_counter()
90
+ row = db.execute("SELECT * FROM orders WHERE id = %s", (order_id,)).fetchone()
91
+ spans().database("SELECT * FROM orders WHERE id = %s", (time.perf_counter() - started) * 1000)
92
+ return row
93
+ ```
94
+
95
+ ## WSGI
96
+
97
+ Any WSGI app gets the same request transactions from
98
+ `errorgap.wsgi.ErrorgapMiddleware`. Set `environ["errorgap.route"]` to the
99
+ matched route template to group requests (otherwise the raw path is used).
100
+
101
+ ```python
102
+ from errorgap.wsgi import ErrorgapMiddleware
103
+
104
+ application = ErrorgapMiddleware(application)
105
+ ```
106
+
107
+ ## Transactions and jobs
108
+
109
+ ```python
110
+ with errorgap.track_transaction("GET", "/orders/{id}", "/orders/123") as txn:
111
+ txn.spans.database("SELECT * FROM orders WHERE id = 123", 4.2)
112
+ txn.status_code = 200
113
+
114
+ with errorgap.track_job("ReceiptJob", queue="mailers") as txn:
115
+ send_receipt()
116
+ ```
117
+
118
+ Both time the block and deliver on exit even if it raises. Errors reported
119
+ inside carry the transaction id (`context.transaction_id`), so errorgap shows
120
+ the error a request raised on its trace. The id lives in a `ContextVar`, so
121
+ concurrent threads and asyncio tasks never share one;
122
+ `errorgap.current_transaction_id()` returns the id in effect.
123
+
124
+ ## Browser trace links
125
+
126
+ When the errorgap browser SDK (`@errorgap/browser` 0.3+) is on the page, its
127
+ API calls send an `x-errorgap-trace` header. The WSGI, Django and FastAPI
128
+ middleware (and so Flask's `init_app`) record it, and `track_transaction(..., trace_id=header)` accepts
129
+ it, so errorgap's browser Performance view links each call to the server
130
+ request that answered it. Malformed values are ignored.
131
+
74
132
  ## FastAPI
75
133
 
76
134
  ```python
@@ -81,6 +139,12 @@ app = FastAPI()
81
139
  app.add_middleware(ErrorgapMiddleware)
82
140
  ```
83
141
 
142
+ With `apm_enabled=True` each request is also an APM transaction grouped by
143
+ its route path (`/orders/{order_id}`); errors raised during it carry its
144
+ transaction id, and the browser SDK's `x-errorgap-trace` header is recorded.
145
+ Record spans from an endpoint with `errorgap.fastapi.spans(request).database(sql, ms)`
146
+ (take `request: Request` as a parameter).
147
+
84
148
  ## Configuration reference
85
149
 
86
150
  | Argument | Default | Notes |
@@ -94,6 +158,8 @@ app.add_middleware(ErrorgapMiddleware)
94
158
  | `async_` | `True` | Background-thread delivery |
95
159
  | `logger` | `logging.getLogger("errorgap")` | Pass `None` to silence |
96
160
  | `filter_keys` | `("password", "token", ...)` | Substring match, case-insensitive |
161
+ | `apm_enabled` | `False` | Send APM transactions |
162
+ | `apm_sample_rate` | `1.0` | Fraction of transactions sent (errors are unaffected) |
97
163
  | `capture_globals` | `True` | Install `sys.excepthook` |
98
164
 
99
165
  ## Graceful shutdown
@@ -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.3.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,12 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import time
4
+ from datetime import datetime, timezone
3
5
  from typing import Any, Callable, Dict, Optional
4
6
 
5
7
  from . import notify
8
+ from .apm import SpanCollector, Transaction, browser_trace_id
9
+ from .transaction_context import new_transaction_id, transaction_scope
10
+
11
+ _TRANSACTION_ATTR = "errorgap_transaction"
6
12
 
7
13
 
8
14
  class ErrorgapMiddleware:
9
- """Django middleware that reports unhandled exceptions to Errorgap.
15
+ """Django middleware that reports unhandled exceptions to Errorgap and
16
+ times each request as an APM transaction (sent with ``apm_enabled``),
17
+ grouped by its URL pattern. Errors raised during the request carry its
18
+ transaction id, and the browser SDK's ``x-errorgap-trace`` header links
19
+ the browser's view of the call to it.
10
20
 
11
21
  Add ``"errorgap.django.ErrorgapMiddleware"`` to ``MIDDLEWARE``. Place it
12
22
  early so it sees exceptions raised by inner middleware too.
@@ -16,7 +26,30 @@ class ErrorgapMiddleware:
16
26
  self.get_response = get_response
17
27
 
18
28
  def __call__(self, request: Any) -> Any:
19
- return self.get_response(request)
29
+ meta = getattr(request, "META", {}) or {}
30
+ txn = Transaction(
31
+ kind="web",
32
+ id=new_transaction_id(),
33
+ trace_id=browser_trace_id(meta.get("HTTP_X_ERRORGAP_TRACE")),
34
+ method=getattr(request, "method", None),
35
+ path_raw=getattr(request, "path", None) or "/",
36
+ occurred_at=datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
37
+ )
38
+ try:
39
+ setattr(request, _TRANSACTION_ATTR, txn)
40
+ except Exception: # noqa: BLE001
41
+ pass
42
+ start = time.perf_counter()
43
+ response = None
44
+ try:
45
+ with transaction_scope(txn.id or new_transaction_id()):
46
+ response = self.get_response(request)
47
+ return response
48
+ finally:
49
+ txn.status_code = getattr(response, "status_code", None) or 500
50
+ txn.duration_ms = (time.perf_counter() - start) * 1000.0
51
+ txn.path = _route(request) or txn.path_raw
52
+ _send_transaction(txn)
20
53
 
21
54
  def process_exception(self, request: Any, exception: BaseException) -> Optional[Any]:
22
55
  notify(
@@ -31,6 +64,28 @@ class ErrorgapMiddleware:
31
64
  return None
32
65
 
33
66
 
67
+ def spans(request: Any) -> Optional[SpanCollector]:
68
+ """The span collector for ``request``'s transaction, for recording DB and
69
+ outbound HTTP spans from views."""
70
+ txn = getattr(request, _TRANSACTION_ATTR, None)
71
+ return txn.spans if txn is not None else None
72
+
73
+
74
+ def _route(request: Any) -> Optional[str]:
75
+ """The matched URL pattern (``/orders/<int:id>``), when one matched."""
76
+ match = getattr(request, "resolver_match", None)
77
+ route = getattr(match, "route", None) if match is not None else None
78
+ if not route:
79
+ return None
80
+ return route if route.startswith("/") else "/" + route
81
+
82
+
83
+ def _send_transaction(txn: Transaction) -> None:
84
+ import errorgap
85
+
86
+ errorgap.notify_transaction(txn)
87
+
88
+
34
89
  def _context(request: Any) -> Dict[str, Any]:
35
90
  try:
36
91
  resolver = getattr(request, "resolver_match", None)