watchdock-errors 0.2.3__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 (27) hide show
  1. {watchdock_errors-0.2.3/src/watchdock_errors.egg-info → watchdock_errors-0.3.0}/PKG-INFO +23 -1
  2. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/README.md +22 -0
  3. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/src/watchdock_errors/__init__.py +26 -5
  4. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/src/watchdock_errors/event.py +8 -1
  5. watchdock_errors-0.3.0/src/watchdock_errors/utils.py +92 -0
  6. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0/src/watchdock_errors.egg-info}/PKG-INFO +23 -1
  7. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/src/watchdock_errors.egg-info/SOURCES.txt +2 -1
  8. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/src/watchdock_errors.egg-info/scm_file_list.json +9 -8
  9. watchdock_errors-0.3.0/src/watchdock_errors.egg-info/scm_version.json +8 -0
  10. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/tests/test_event.py +51 -0
  11. watchdock_errors-0.3.0/tests/test_utils.py +44 -0
  12. watchdock_errors-0.2.3/src/watchdock_errors/utils.py +0 -33
  13. watchdock_errors-0.2.3/src/watchdock_errors.egg-info/scm_version.json +0 -8
  14. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/.github/workflows/ci.yml +0 -0
  15. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/.github/workflows/release.yml +0 -0
  16. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/.gitignore +0 -0
  17. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/pyproject.toml +0 -0
  18. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/setup.cfg +0 -0
  19. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/src/watchdock_errors/client.py +0 -0
  20. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/src/watchdock_errors/config.py +0 -0
  21. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/src/watchdock_errors/integrations/__init__.py +0 -0
  22. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/src/watchdock_errors/integrations/django.py +0 -0
  23. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/src/watchdock_errors/integrations/fastapi.py +0 -0
  24. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/src/watchdock_errors.egg-info/dependency_links.txt +0 -0
  25. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/src/watchdock_errors.egg-info/requires.txt +0 -0
  26. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/src/watchdock_errors.egg-info/top_level.txt +0 -0
  27. {watchdock_errors-0.2.3 → watchdock_errors-0.3.0}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: watchdock-errors
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: Application error tracking SDK for the Watchdock platform
5
5
  License: MIT
6
6
  Keywords: error-tracking,observability,watchdock
@@ -88,8 +88,30 @@ watchdock_errors.capture_exception(exc)
88
88
 
89
89
  # Capture a message
90
90
  watchdock_errors.capture_message("Stripe webhook signature invalid")
91
+
92
+ # Capture a message with a custom level
93
+ watchdock_errors.capture_message("Queue depth high", level="warning")
91
94
  ```
92
95
 
96
+ ### Event levels
97
+
98
+ Every event carries a `level`. Exceptions default to `"error"`; messages default to `"info"` unless you pass `level` explicitly to `capture_message`.
99
+
100
+ ## Correlating with nginx requests
101
+
102
+ If your app is behind nginx and you've added `$request_id` to your access log format (see the [nginx log collection docs](https://watchdock.cc/docs/nginx-log-collection)) and forwarded it to your app via `proxy_set_header X-Request-Id $request_id;`, this SDK automatically reads that header off every captured request and attaches it as `trace_id` — no code changes needed. This lets WatchDock link a failed request in your nginx access logs directly to the exception it produced.
103
+
104
+ You can also pass `trace_id` explicitly, which takes priority over the auto-extracted value:
105
+
106
+ ```python
107
+ watchdock_errors.capture_exception(exc, trace_id=my_trace_id)
108
+ watchdock_errors.capture_message("Queue depth high", level="warning", trace_id=my_trace_id)
109
+ ```
110
+
111
+ ## SDK initialization
112
+
113
+ When `init()` is called, the SDK sends a one-time, fire-and-forget ping to the platform (with the SDK version and environment) to register that it started up. This never blocks application startup and any failure is silently ignored.
114
+
93
115
  ## PII scrubbing
94
116
 
95
117
  By default, `Authorization`, `Cookie`, and `X-Api-Key` headers are stripped and the request body is not sent. Set `send_pii=True` to disable scrubbing.
@@ -68,8 +68,30 @@ watchdock_errors.capture_exception(exc)
68
68
 
69
69
  # Capture a message
70
70
  watchdock_errors.capture_message("Stripe webhook signature invalid")
71
+
72
+ # Capture a message with a custom level
73
+ watchdock_errors.capture_message("Queue depth high", level="warning")
71
74
  ```
72
75
 
76
+ ### Event levels
77
+
78
+ Every event carries a `level`. Exceptions default to `"error"`; messages default to `"info"` unless you pass `level` explicitly to `capture_message`.
79
+
80
+ ## Correlating with nginx requests
81
+
82
+ If your app is behind nginx and you've added `$request_id` to your access log format (see the [nginx log collection docs](https://watchdock.cc/docs/nginx-log-collection)) and forwarded it to your app via `proxy_set_header X-Request-Id $request_id;`, this SDK automatically reads that header off every captured request and attaches it as `trace_id` — no code changes needed. This lets WatchDock link a failed request in your nginx access logs directly to the exception it produced.
83
+
84
+ You can also pass `trace_id` explicitly, which takes priority over the auto-extracted value:
85
+
86
+ ```python
87
+ watchdock_errors.capture_exception(exc, trace_id=my_trace_id)
88
+ watchdock_errors.capture_message("Queue depth high", level="warning", trace_id=my_trace_id)
89
+ ```
90
+
91
+ ## SDK initialization
92
+
93
+ When `init()` is called, the SDK sends a one-time, fire-and-forget ping to the platform (with the SDK version and environment) to register that it started up. This never blocks application startup and any failure is silently ignored.
94
+
73
95
  ## PII scrubbing
74
96
 
75
97
  By default, `Authorization`, `Cookie`, and `X-Api-Key` headers are stripped and the request body is not sent. Set `send_pii=True` to disable scrubbing.
@@ -77,13 +77,21 @@ def init(
77
77
  _send_init_ping(_config)
78
78
 
79
79
 
80
- def capture_exception(exc: BaseException | None = None, request_context: dict | None = None) -> None:
80
+ def capture_exception(
81
+ exc: BaseException | None = None,
82
+ request_context: dict | None = None,
83
+ trace_id: str | None = None,
84
+ ) -> None:
81
85
  """
82
86
  Capture an exception and send it to Watchdock.
83
87
 
84
88
  If ``exc`` is None the current exception from ``sys.exc_info()`` is used.
85
89
  Safe to call outside of an except block — does nothing if there is no
86
90
  active exception and ``exc`` is not provided.
91
+
92
+ ``trace_id`` correlates this event with the originating nginx request
93
+ (e.g. ``X-Request-Id``) and takes priority over any value auto-extracted
94
+ from ``request_context``'s headers if both are present.
87
95
  """
88
96
  if _client is None or _config is None:
89
97
  return
@@ -97,7 +105,7 @@ def capture_exception(exc: BaseException | None = None, request_context: dict |
97
105
 
98
106
  from .event import build_event
99
107
 
100
- event = build_event(exc, _config, request_context=request_context)
108
+ event = build_event(exc, _config, request_context=request_context, trace_id=trace_id)
101
109
  if event is not None:
102
110
  logger.info("watchdock_errors: capturing exception — %s: %s", type(exc).__name__, exc)
103
111
  _client.capture(event)
@@ -105,14 +113,27 @@ def capture_exception(exc: BaseException | None = None, request_context: dict |
105
113
  logger.debug("watchdock_errors: event dropped by before_send hook")
106
114
 
107
115
 
108
- def capture_message(message: str, level: str = "info", request_context: dict | None = None) -> None:
109
- """Capture an arbitrary message string and send it to Watchdock."""
116
+ def capture_message(
117
+ message: str,
118
+ level: str = "info",
119
+ request_context: dict | None = None,
120
+ trace_id: str | None = None,
121
+ ) -> None:
122
+ """
123
+ Capture an arbitrary message string and send it to Watchdock.
124
+
125
+ ``trace_id`` correlates this event with the originating nginx request
126
+ (e.g. ``X-Request-Id``) and takes priority over any value auto-extracted
127
+ from ``request_context``'s headers if both are present.
128
+ """
110
129
  if _client is None or _config is None:
111
130
  return
112
131
 
113
132
  from .event import build_event
114
133
 
115
- event = build_event(None, _config, message=message, level=level, request_context=request_context)
134
+ event = build_event(
135
+ None, _config, message=message, level=level, request_context=request_context, trace_id=trace_id
136
+ )
116
137
  if event is not None:
117
138
  logger.info("watchdock_errors: capturing message — %s", message)
118
139
  _client.capture(event)
@@ -4,7 +4,7 @@ import datetime
4
4
  import logging
5
5
 
6
6
  from .config import SDKConfig
7
- from .utils import extract_stacktrace, get_server_info
7
+ from .utils import extract_stacktrace, extract_trace_id, get_server_info
8
8
 
9
9
  logger = logging.getLogger("watchdock_errors")
10
10
 
@@ -15,6 +15,7 @@ def build_event(
15
15
  message: str | None = None,
16
16
  level: str | None = None,
17
17
  request_context: dict | None = None,
18
+ trace_id: str | None = None,
18
19
  ) -> dict | None:
19
20
  """
20
21
  Build a Watchdock error event payload.
@@ -61,6 +62,12 @@ def build_event(
61
62
  if config.server_name:
62
63
  event["server"]["server_name"] = config.server_name
63
64
 
65
+ resolved_trace_id = trace_id or (
66
+ extract_trace_id(request_context.get("request", {}).get("headers", {})) if request_context else None
67
+ )
68
+ if resolved_trace_id:
69
+ event["trace_id"] = resolved_trace_id
70
+
64
71
  if request_context:
65
72
  if config.send_pii:
66
73
  event["request"] = request_context.get("request", {})
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import linecache
4
+ import platform
5
+ import socket
6
+ import traceback
7
+
8
+
9
+ #: Number of source lines captured on each side of the failing line. Matches the
10
+ #: window the backend uses when it falls back to enriching frames itself, so a
11
+ #: frame looks the same whether the context came from here or from the server.
12
+ CONTEXT_LINES = 2
13
+
14
+
15
+ def _source_context(filename: str, lineno: int) -> tuple[list[str], list[str]]:
16
+ """Read the source lines immediately before and after ``lineno``.
17
+
18
+ Returns ``(pre_context, post_context)``. We do this in the SDK, on the box
19
+ where the code actually runs, because the source is here — the backend can
20
+ only guess at it from its own filesystem, which is the wrong machine for any
21
+ real app. Out-of-range lines come back as empty strings so the two lists are
22
+ always the same length regardless of where in the file the error landed.
23
+ """
24
+ if not filename or not lineno:
25
+ return [], []
26
+
27
+ pre = [
28
+ linecache.getline(filename, i).rstrip("\n")
29
+ for i in range(max(1, lineno - CONTEXT_LINES), lineno)
30
+ ]
31
+ post = [
32
+ linecache.getline(filename, i).rstrip("\n")
33
+ for i in range(lineno + 1, lineno + CONTEXT_LINES + 1)
34
+ ]
35
+ return pre, post
36
+
37
+
38
+ def extract_stacktrace(exc: BaseException) -> list[dict]:
39
+ """Extract structured stack frames from an exception."""
40
+ tb = exc.__traceback__
41
+ if tb is None:
42
+ return []
43
+
44
+ frames = []
45
+ for frame_summary in traceback.extract_tb(tb):
46
+ context_line = linecache.getline(frame_summary.filename, frame_summary.lineno).strip()
47
+ pre_context, post_context = _source_context(frame_summary.filename, frame_summary.lineno)
48
+ frames.append(
49
+ {
50
+ "filename": frame_summary.filename,
51
+ "function": frame_summary.name,
52
+ "lineno": frame_summary.lineno,
53
+ "context_line": context_line,
54
+ "pre_context": pre_context,
55
+ "post_context": post_context,
56
+ }
57
+ )
58
+ return frames
59
+
60
+
61
+ def get_server_info() -> dict:
62
+ return {
63
+ "hostname": socket.gethostname(),
64
+ "python_version": platform.python_version(),
65
+ }
66
+
67
+
68
+ def extract_trace_id(headers: dict | None) -> str | None:
69
+ """
70
+ Pull a correlation ID off incoming request headers so this event can be
71
+ linked back to the nginx access/error log line for the same request.
72
+
73
+ Prefers ``X-Request-Id`` (nginx's built-in ``$request_id``, zero extra
74
+ modules required) and falls back to the trace-id segment of a W3C
75
+ ``traceparent`` header if present.
76
+ """
77
+ if not headers:
78
+ return None
79
+
80
+ normalized = {str(k).lower(): v for k, v in headers.items()}
81
+
82
+ request_id = normalized.get("x-request-id")
83
+ if request_id:
84
+ return request_id
85
+
86
+ traceparent = normalized.get("traceparent")
87
+ if traceparent:
88
+ parts = traceparent.split("-")
89
+ if len(parts) >= 2 and parts[1]:
90
+ return parts[1]
91
+
92
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: watchdock-errors
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: Application error tracking SDK for the Watchdock platform
5
5
  License: MIT
6
6
  Keywords: error-tracking,observability,watchdock
@@ -88,8 +88,30 @@ watchdock_errors.capture_exception(exc)
88
88
 
89
89
  # Capture a message
90
90
  watchdock_errors.capture_message("Stripe webhook signature invalid")
91
+
92
+ # Capture a message with a custom level
93
+ watchdock_errors.capture_message("Queue depth high", level="warning")
91
94
  ```
92
95
 
96
+ ### Event levels
97
+
98
+ Every event carries a `level`. Exceptions default to `"error"`; messages default to `"info"` unless you pass `level` explicitly to `capture_message`.
99
+
100
+ ## Correlating with nginx requests
101
+
102
+ If your app is behind nginx and you've added `$request_id` to your access log format (see the [nginx log collection docs](https://watchdock.cc/docs/nginx-log-collection)) and forwarded it to your app via `proxy_set_header X-Request-Id $request_id;`, this SDK automatically reads that header off every captured request and attaches it as `trace_id` — no code changes needed. This lets WatchDock link a failed request in your nginx access logs directly to the exception it produced.
103
+
104
+ You can also pass `trace_id` explicitly, which takes priority over the auto-extracted value:
105
+
106
+ ```python
107
+ watchdock_errors.capture_exception(exc, trace_id=my_trace_id)
108
+ watchdock_errors.capture_message("Queue depth high", level="warning", trace_id=my_trace_id)
109
+ ```
110
+
111
+ ## SDK initialization
112
+
113
+ When `init()` is called, the SDK sends a one-time, fire-and-forget ping to the platform (with the SDK version and environment) to register that it started up. This never blocks application startup and any failure is silently ignored.
114
+
93
115
  ## PII scrubbing
94
116
 
95
117
  By default, `Authorization`, `Cookie`, and `X-Api-Key` headers are stripped and the request body is not sent. Set `send_pii=True` to disable scrubbing.
@@ -19,4 +19,5 @@ src/watchdock_errors/integrations/__init__.py
19
19
  src/watchdock_errors/integrations/django.py
20
20
  src/watchdock_errors/integrations/fastapi.py
21
21
  tests/test_client.py
22
- tests/test_event.py
22
+ tests/test_event.py
23
+ tests/test_utils.py
@@ -1,19 +1,20 @@
1
1
  {
2
2
  "files": [
3
+ ".gitignore",
3
4
  "README.md",
4
5
  "pyproject.toml",
5
- ".gitignore",
6
- "src/watchdock_errors/__init__.py",
6
+ "src/watchdock_errors/event.py",
7
+ "src/watchdock_errors/client.py",
7
8
  "src/watchdock_errors/config.py",
8
9
  "src/watchdock_errors/utils.py",
9
- "src/watchdock_errors/client.py",
10
- "src/watchdock_errors/event.py",
11
- "src/watchdock_errors/integrations/__init__.py",
10
+ "src/watchdock_errors/__init__.py",
12
11
  "src/watchdock_errors/integrations/fastapi.py",
13
12
  "src/watchdock_errors/integrations/django.py",
14
- "tests/test_client.py",
15
- "tests/test_event.py",
13
+ "src/watchdock_errors/integrations/__init__.py",
14
+ ".github/workflows/ci.yml",
16
15
  ".github/workflows/release.yml",
17
- ".github/workflows/ci.yml"
16
+ "tests/test_event.py",
17
+ "tests/test_client.py",
18
+ "tests/test_utils.py"
18
19
  ]
19
20
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.3.0",
3
+ "distance": 0,
4
+ "node": "g711d547840b425c8a17c295e36cd0e731bdadbc4",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-22"
8
+ }
@@ -94,3 +94,54 @@ def test_pii_headers_scrubbed_by_default(config):
94
94
 
95
95
  assert "Authorization" not in event["request"]["headers"]
96
96
  assert event["request"]["headers"]["Content-Type"] == "application/json"
97
+
98
+
99
+ def test_trace_id_extracted_from_x_request_id_header(config):
100
+ req_ctx = {
101
+ "request": {
102
+ "method": "GET",
103
+ "url": "/checkout/",
104
+ "headers": {"X-Request-Id": "abc123"},
105
+ }
106
+ }
107
+
108
+ try:
109
+ raise ValueError("x")
110
+ except ValueError as exc:
111
+ event = build_event(exc, config, request_context=req_ctx)
112
+
113
+ assert event["trace_id"] == "abc123"
114
+
115
+
116
+ def test_trace_id_falls_back_to_traceparent_header(config):
117
+ req_ctx = {
118
+ "request": {
119
+ "headers": {"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"},
120
+ }
121
+ }
122
+
123
+ event = build_event(None, config, message="hi", request_context=req_ctx)
124
+
125
+ assert event["trace_id"] == "4bf92f3577b34da6a3ce929d0e0e4736"
126
+
127
+
128
+ def test_trace_id_absent_when_no_matching_header(config):
129
+ req_ctx = {"request": {"headers": {"Content-Type": "application/json"}}}
130
+
131
+ event = build_event(None, config, message="hi", request_context=req_ctx)
132
+
133
+ assert "trace_id" not in event
134
+
135
+
136
+ def test_explicit_trace_id_wins_over_header_extraction(config):
137
+ req_ctx = {"request": {"headers": {"X-Request-Id": "from-header"}}}
138
+
139
+ event = build_event(None, config, message="hi", request_context=req_ctx, trace_id="explicit-id")
140
+
141
+ assert event["trace_id"] == "explicit-id"
142
+
143
+
144
+ def test_explicit_trace_id_works_without_request_context(config):
145
+ event = build_event(None, config, message="hi", trace_id="explicit-id")
146
+
147
+ assert event["trace_id"] == "explicit-id"
@@ -0,0 +1,44 @@
1
+ from watchdock_errors.utils import extract_stacktrace
2
+
3
+
4
+ def _boom():
5
+ # This line and the two on either side are what the context capture should
6
+ # pick up. Keep the surrounding lines stable so the assertions below hold.
7
+ x = 1
8
+ raise ValueError("boom") # <-- failing line
9
+ return x
10
+
11
+
12
+ def test_extract_stacktrace_captures_source_context():
13
+ try:
14
+ _boom()
15
+ except ValueError as exc:
16
+ frames = extract_stacktrace(exc)
17
+
18
+ assert frames, "expected at least one frame"
19
+
20
+ # The frame for _boom is the one whose failing line raised.
21
+ boom_frame = next(f for f in frames if f["function"] == "_boom")
22
+
23
+ assert boom_frame["context_line"] == 'raise ValueError("boom") # <-- failing line'
24
+ # Two lines before and two after, in order, with indentation preserved.
25
+ assert len(boom_frame["pre_context"]) == 2
26
+ assert len(boom_frame["post_context"]) == 2
27
+ # The line immediately before the raise is `x = 1` (indentation kept).
28
+ assert boom_frame["pre_context"][1] == " x = 1"
29
+ # The line immediately after the raise is the return.
30
+ assert boom_frame["post_context"][0] == " return x"
31
+
32
+
33
+ def test_frames_always_have_context_keys():
34
+ try:
35
+ raise KeyError("missing")
36
+ except KeyError as exc:
37
+ frames = extract_stacktrace(exc)
38
+
39
+ for frame in frames:
40
+ assert "context_line" in frame
41
+ assert "pre_context" in frame
42
+ assert "post_context" in frame
43
+ assert isinstance(frame["pre_context"], list)
44
+ assert isinstance(frame["post_context"], list)
@@ -1,33 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import linecache
4
- import platform
5
- import socket
6
- import traceback
7
-
8
-
9
- def extract_stacktrace(exc: BaseException) -> list[dict]:
10
- """Extract structured stack frames from an exception."""
11
- tb = exc.__traceback__
12
- if tb is None:
13
- return []
14
-
15
- frames = []
16
- for frame_summary in traceback.extract_tb(tb):
17
- context_line = linecache.getline(frame_summary.filename, frame_summary.lineno).strip()
18
- frames.append(
19
- {
20
- "filename": frame_summary.filename,
21
- "function": frame_summary.name,
22
- "lineno": frame_summary.lineno,
23
- "context_line": context_line,
24
- }
25
- )
26
- return frames
27
-
28
-
29
- def get_server_info() -> dict:
30
- return {
31
- "hostname": socket.gethostname(),
32
- "python_version": platform.python_version(),
33
- }
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.2.3",
3
- "distance": 0,
4
- "node": "g552866cfdf269b7f1dd8cf126bc25eb5623849cb",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-07-01"
8
- }