fetchnode 0.4.0__py3-none-any.whl

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.
fetchnode/celery.py ADDED
@@ -0,0 +1,138 @@
1
+ """Optional Celery signal instrumentation with no Celery hard dependency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ import uuid
8
+
9
+ from fetchnode.api import capture_exception
10
+ from fetchnode.capture import track_job_execution
11
+
12
+
13
+ _started = {}
14
+ _finished = {}
15
+ _lock = threading.Lock()
16
+ _installed = False
17
+
18
+
19
+ def _request_details(task, task_id: str) -> dict:
20
+ request = getattr(task, "request", None)
21
+ headers = getattr(request, "headers", None) or {}
22
+ delivery = getattr(request, "delivery_info", None) or {}
23
+ sent_at = headers.get("fetchnode_sent_at")
24
+ try:
25
+ queue_latency_ms = max(0, (time.time() - float(sent_at)) * 1000) if sent_at else 0
26
+ except (TypeError, ValueError):
27
+ queue_latency_ms = 0
28
+ return {
29
+ "task_id": task_id,
30
+ "task_name": getattr(task, "name", "") or type(task).__name__,
31
+ "queue": str(delivery.get("routing_key") or ""),
32
+ "retry_count": int(getattr(request, "retries", 0) or 0),
33
+ "queue_latency_ms": queue_latency_ms,
34
+ "trace_id": str(headers.get("fetchnode_trace_id") or uuid.uuid4().hex),
35
+ "parent_trace_id": str(headers.get("fetchnode_parent_trace_id") or ""),
36
+ }
37
+
38
+
39
+ def _before_publish(headers=None, **kwargs):
40
+ if headers is None:
41
+ return
42
+ headers.setdefault("fetchnode_sent_at", time.time())
43
+ headers.setdefault("fetchnode_trace_id", uuid.uuid4().hex)
44
+
45
+
46
+ def _task_prerun(task_id=None, task=None, **kwargs):
47
+ details = _request_details(task, str(task_id or ""))
48
+ with _lock:
49
+ identifier = str(task_id or "")
50
+ _started[identifier] = (time.monotonic(), details)
51
+ _finished.pop(identifier, None)
52
+
53
+
54
+ def _pop_details(task_id, task):
55
+ with _lock:
56
+ stored = _started.pop(str(task_id or ""), None)
57
+ if stored:
58
+ return stored
59
+ return time.monotonic(), _request_details(task, str(task_id or ""))
60
+
61
+
62
+ def _mark_finished(task_id) -> None:
63
+ now = time.monotonic()
64
+ identifier = str(task_id or "")
65
+ with _lock:
66
+ for stale_id, finished_at in list(_finished.items()):
67
+ if now - finished_at > 3600:
68
+ _finished.pop(stale_id, None)
69
+ _finished[identifier] = now
70
+
71
+
72
+ def _consume_finished(task_id) -> bool:
73
+ with _lock:
74
+ return _finished.pop(str(task_id or ""), None) is not None
75
+
76
+
77
+ def _task_postrun(task_id=None, task=None, state=None, **kwargs):
78
+ if _consume_finished(task_id):
79
+ return
80
+ started, details = _pop_details(task_id, task)
81
+ normalized_state = str(state or "").upper()
82
+ status = "succeeded" if normalized_state == "SUCCESS" else "retrying" if normalized_state == "RETRY" else "failed"
83
+ track_job_execution(
84
+ **details,
85
+ status=status,
86
+ duration_ms=(time.monotonic() - started) * 1000,
87
+ runtime="celery",
88
+ )
89
+
90
+
91
+ def _task_failure(task_id=None, exception=None, sender=None, **kwargs):
92
+ task = sender
93
+ started, details = _pop_details(task_id, task)
94
+ if exception is not None:
95
+ capture_exception(
96
+ exception,
97
+ tags={"runtime": "celery", "task_name": details["task_name"], "task_id": details["task_id"]},
98
+ logger_name=f"celery.{details['task_name']}",
99
+ mechanism="celery.signal",
100
+ handled=False,
101
+ )
102
+ track_job_execution(
103
+ **details,
104
+ status="failed",
105
+ duration_ms=(time.monotonic() - started) * 1000,
106
+ runtime="celery",
107
+ )
108
+ _mark_finished(task_id)
109
+
110
+
111
+ def _task_retry(request=None, reason=None, sender=None, **kwargs):
112
+ task_id = str(getattr(request, "id", "") or "")
113
+ started, details = _pop_details(task_id, sender)
114
+ track_job_execution(
115
+ **details,
116
+ status="retrying",
117
+ duration_ms=(time.monotonic() - started) * 1000,
118
+ runtime="celery",
119
+ properties={"reason": str(reason or "")[:500]},
120
+ )
121
+ _mark_finished(task_id)
122
+
123
+
124
+ def install_celery_instrumentation() -> bool:
125
+ global _installed
126
+ if _installed:
127
+ return True
128
+ try:
129
+ from celery.signals import before_task_publish, task_failure, task_postrun, task_prerun, task_retry
130
+ except ImportError:
131
+ return False
132
+ before_task_publish.connect(_before_publish, weak=False, dispatch_uid="fetchnode.before_publish")
133
+ task_prerun.connect(_task_prerun, weak=False, dispatch_uid="fetchnode.task_prerun")
134
+ task_postrun.connect(_task_postrun, weak=False, dispatch_uid="fetchnode.task_postrun")
135
+ task_failure.connect(_task_failure, weak=False, dispatch_uid="fetchnode.task_failure")
136
+ task_retry.connect(_task_retry, weak=False, dispatch_uid="fetchnode.task_retry")
137
+ _installed = True
138
+ return True
fetchnode/conf.py ADDED
@@ -0,0 +1,127 @@
1
+ import os
2
+ from django.conf import settings
3
+
4
+
5
+ DEFAULTS = {
6
+ "ENABLED": False,
7
+ "API_URL": "https://monitor.fetchnode.com/api/events/ingest/",
8
+ "API_KEY": "",
9
+ "ENVIRONMENT": "development",
10
+ "RELEASE": "",
11
+ "TIMEOUT": 2,
12
+ "MAX_RETRIES": 3,
13
+ "BATCH_SIZE": 10,
14
+ "COMPRESS_PAYLOADS": True,
15
+ "COMPRESSION_THRESHOLD_BYTES": 1024,
16
+ "DISK_BUFFER_PATH": "",
17
+ "MAX_BUFFERED_EVENTS": 1000,
18
+ "SEND_CLIENT_REPORTS": True,
19
+ "CLIENT_REPORT_INTERVAL_SECONDS": 60,
20
+ "TRANSACTION_SAMPLE_RATE": 1.0,
21
+ "JOB_SAMPLE_RATE": 1.0,
22
+ "KEEP_TRANSACTION_OUTLIER_MS": 1000,
23
+ "CAPTURE_TRANSACTIONS": True,
24
+ "CAPTURE_JOBS": True,
25
+ "INSTRUMENT_CELERY": True,
26
+ "INSTRUMENT_LOGGING": True,
27
+ "INSTRUMENT_EXCEPTHOOK": True,
28
+ "IGNORE_PATH_PREFIXES": [
29
+ "/api/events/ingest/", "/widget/", "/static/", "/admin/jsi18n/", "/health/", "/ready/",
30
+ ],
31
+ "CAPTURE_HEADERS": False,
32
+ "CAPTURE_BODY": False,
33
+ "CAPTURE_IP_ADDRESS": False,
34
+ "CAPTURE_QUERY_STRING": False,
35
+ "CAPTURE_QUERY_TEXT": False,
36
+ "CAPTURE_QUERY_SHAPE": True,
37
+ "QUERY_SLOW_THRESHOLD_MS": 100,
38
+ "N_PLUS_ONE_THRESHOLD": 5,
39
+ "MAX_QUERY_OBSERVATIONS": 50,
40
+ "TRACE_RESPONSE_HEADER": True,
41
+ "CAPTURE_USER_ID": True,
42
+ "CAPTURE_USER_EMAIL": False,
43
+ "CAPTURE_USERNAME": False,
44
+ "MAX_BODY_LENGTH": 4000,
45
+ "SENSITIVE_KEYS": [
46
+ "password",
47
+ "passwd",
48
+ "secret",
49
+ "token",
50
+ "api_key",
51
+ "access_token",
52
+ "refresh_token",
53
+ "authorization",
54
+ "cookie",
55
+ "csrfmiddlewaretoken",
56
+ ],
57
+ "SENSITIVE_HEADERS": ["authorization", "cookie", "x-api-key"],
58
+ }
59
+
60
+
61
+ def get_client_settings() -> dict:
62
+ configured = getattr(settings, "FETCHNODE", None)
63
+ if configured is None:
64
+ configured = getattr(settings, "FETCHNODE_CLIENT", {})
65
+ if not isinstance(configured, dict):
66
+ configured = {}
67
+
68
+ merged = DEFAULTS.copy()
69
+ merged.update(configured)
70
+
71
+ # API key resolution: API_KEY, KEY, PROJECT_KEY, or env
72
+ api_key = (
73
+ configured.get("API_KEY")
74
+ or configured.get("KEY")
75
+ or configured.get("PROJECT_KEY")
76
+ or os.environ.get("FETCHNODE_API_KEY", "")
77
+ or os.environ.get("FETCHNODE_KEY", "")
78
+ )
79
+ merged["API_KEY"] = str(api_key or "").strip()
80
+
81
+ # URL resolution: API_URL, ENDPOINT, DSN, HOST, or env
82
+ api_url = (
83
+ configured.get("API_URL")
84
+ or configured.get("ENDPOINT")
85
+ or configured.get("DSN")
86
+ or configured.get("HOST")
87
+ or os.environ.get("FETCHNODE_API_URL", "")
88
+ or os.environ.get("FETCHNODE_ENDPOINT", "")
89
+ or DEFAULTS["API_URL"]
90
+ )
91
+ api_url = str(api_url or "").strip()
92
+ if api_url and not api_url.rstrip("/").endswith(("/api/events/ingest", "/api/events", "/events")):
93
+ api_url = api_url.rstrip("/") + "/api/events/ingest/"
94
+ merged["API_URL"] = api_url
95
+
96
+ # Environment resolution
97
+ env = (
98
+ configured.get("ENVIRONMENT")
99
+ or os.environ.get("FETCHNODE_ENVIRONMENT")
100
+ or getattr(settings, "DJANGO_ENV", "")
101
+ or "development"
102
+ )
103
+ merged["ENVIRONMENT"] = str(env or "").strip()
104
+
105
+ # Release resolution
106
+ release = (
107
+ configured.get("RELEASE")
108
+ or os.environ.get("FETCHNODE_RELEASE")
109
+ or getattr(settings, "GIT_COMMIT", "")
110
+ or ""
111
+ )
112
+ merged["RELEASE"] = str(release or "").strip()
113
+
114
+ # Enabled: if specified explicitly, respect it.
115
+ # Otherwise auto-enable if an API key is present!
116
+ if "ENABLED" in configured:
117
+ merged["ENABLED"] = bool(configured["ENABLED"])
118
+ elif "FETCHNODE_ENABLED" in os.environ:
119
+ merged["ENABLED"] = os.environ["FETCHNODE_ENABLED"].lower() in {"1", "true", "yes"}
120
+ else:
121
+ merged["ENABLED"] = bool(merged["API_KEY"])
122
+
123
+ # Security filters are additive: a project can protect extra business
124
+ # fields without accidentally unprotecting cookies or authorization data.
125
+ for field in ("SENSITIVE_KEYS", "SENSITIVE_HEADERS"):
126
+ merged[field] = list(dict.fromkeys([*DEFAULTS[field], *configured.get(field, [])]))
127
+ return merged
fetchnode/demo.py ADDED
@@ -0,0 +1,3 @@
1
+ def boom():
2
+ raise ValueError("Boom from demo helper")
3
+
@@ -0,0 +1,83 @@
1
+ """Global unhandled exception hook for scripts, cron jobs, and management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import threading
7
+ from typing import Any, Callable
8
+
9
+ from fetchnode.api import capture_exception
10
+ from fetchnode.sender import flush
11
+
12
+ _original_excepthook: Callable[..., Any] | None = None
13
+ _installed: bool = False
14
+ _lock = threading.Lock()
15
+
16
+
17
+ def fetchnode_excepthook(exc_type, exc_value, exc_traceback) -> None:
18
+ """Capture unhandled exceptions, flush the sender queue, and chain to the original hook."""
19
+ try:
20
+ is_ignored = (
21
+ (isinstance(exc_type, type) and issubclass(exc_type, (KeyboardInterrupt, SystemExit)))
22
+ or isinstance(exc_value, (KeyboardInterrupt, SystemExit))
23
+ )
24
+ if not is_ignored:
25
+ exc = exc_value
26
+ if exc is None and isinstance(exc_type, type) and issubclass(exc_type, BaseException):
27
+ try:
28
+ exc = exc_type()
29
+ except Exception:
30
+ try:
31
+ exc = BaseException.__new__(exc_type)
32
+ except Exception:
33
+ exc = None
34
+ if exc is not None and isinstance(exc, BaseException):
35
+ if exc_traceback is not None and getattr(exc, "__traceback__", None) is not exc_traceback:
36
+ try:
37
+ exc = exc.with_traceback(exc_traceback)
38
+ except Exception:
39
+ pass
40
+ capture_exception(exc, mechanism="excepthook", handled=False)
41
+ flush(timeout=2.0)
42
+ except Exception:
43
+ pass
44
+ finally:
45
+ hook = _original_excepthook
46
+ if hook is None or hook is fetchnode_excepthook:
47
+ hook = getattr(sys, "__excepthook__", None)
48
+ if hook is not None and hook is not fetchnode_excepthook:
49
+ hook(exc_type, exc_value, exc_traceback)
50
+
51
+
52
+ def install_excepthook_instrumentation() -> bool:
53
+ """Install sys.excepthook to capture unhandled process crashes."""
54
+ global _installed, _original_excepthook
55
+ with _lock:
56
+ if sys.excepthook is fetchnode_excepthook:
57
+ _installed = True
58
+ if _original_excepthook is None or _original_excepthook is fetchnode_excepthook:
59
+ _original_excepthook = getattr(sys, "__excepthook__", None)
60
+ return True
61
+ _original_excepthook = sys.excepthook
62
+ sys.excepthook = fetchnode_excepthook
63
+ _installed = True
64
+ return True
65
+
66
+
67
+ def uninstall_excepthook_instrumentation() -> bool:
68
+ """Restore sys.excepthook to the previous handler."""
69
+ global _installed, _original_excepthook
70
+ with _lock:
71
+ if not _installed and sys.excepthook is not fetchnode_excepthook:
72
+ return False
73
+ if sys.excepthook is fetchnode_excepthook:
74
+ target = _original_excepthook or getattr(sys, "__excepthook__", None)
75
+ if target is fetchnode_excepthook:
76
+ target = getattr(sys, "__excepthook__", None)
77
+ sys.excepthook = target
78
+ _original_excepthook = None
79
+ _installed = False
80
+ return True
81
+
82
+
83
+ restore_excepthook = uninstall_excepthook_instrumentation
fetchnode/handlers.py ADDED
@@ -0,0 +1,76 @@
1
+ import logging
2
+
3
+ from fetchnode.api import build_exception_payload, capture_message
4
+ from fetchnode.excepthook import (
5
+ fetchnode_excepthook,
6
+ install_excepthook_instrumentation,
7
+ restore_excepthook,
8
+ uninstall_excepthook_instrumentation,
9
+ )
10
+ from fetchnode.sender import send_payload
11
+ from fetchnode.scope import get_request
12
+
13
+
14
+ _CAPTURED_RECORD_ATTRIBUTE = "_fetchnode_logging_captured"
15
+
16
+
17
+ class FetchNodeHandler(logging.Handler):
18
+ def emit(self, record):
19
+ try:
20
+ self._emit(record)
21
+ except Exception:
22
+ self.handleError(record)
23
+
24
+ def _emit(self, record):
25
+ if (
26
+ record.levelno < logging.ERROR
27
+ or record.name == "fetchnode"
28
+ or record.name.startswith("fetchnode.")
29
+ or getattr(record, _CAPTURED_RECORD_ATTRIBUTE, False)
30
+ ):
31
+ return
32
+ setattr(record, _CAPTURED_RECORD_ATTRIBUTE, True)
33
+ request = get_request()
34
+ if not record.exc_info:
35
+ kwargs = {
36
+ "level": record.levelname.lower(),
37
+ "logger_name": record.name,
38
+ "tags": {"logger_handler": "logging"},
39
+ }
40
+ if request is not None:
41
+ kwargs["request"] = request
42
+ capture_message(record.getMessage(), **kwargs)
43
+ return
44
+ exc_value = record.exc_info[1]
45
+ if exc_value is not None:
46
+ kwargs = {
47
+ "logger_name": record.name,
48
+ "tags": {"logger_handler": "logging"},
49
+ "mechanism": "logging",
50
+ "handled": True,
51
+ }
52
+ if request is not None:
53
+ kwargs["request"] = request
54
+ payload = build_exception_payload(exc_value, **kwargs)
55
+ payload["level"] = record.levelname.lower()
56
+ send_payload(payload)
57
+
58
+
59
+ def install_logging_instrumentation() -> FetchNodeHandler:
60
+ """Attach one ERROR+ handler to root and non-propagating application loggers."""
61
+ root_logger = logging.getLogger()
62
+ handler = next(
63
+ (handler for handler in root_logger.handlers if isinstance(handler, FetchNodeHandler)),
64
+ None,
65
+ )
66
+ if handler is None:
67
+ handler = FetchNodeHandler(level=logging.ERROR)
68
+ root_logger.addHandler(handler)
69
+ for application_logger in logging.root.manager.loggerDict.values():
70
+ if (
71
+ isinstance(application_logger, logging.Logger)
72
+ and not application_logger.propagate
73
+ and handler not in application_logger.handlers
74
+ ):
75
+ application_logger.addHandler(handler)
76
+ return handler
@@ -0,0 +1,188 @@
1
+ import time
2
+ import uuid
3
+ import re
4
+
5
+ from django.db import connection
6
+
7
+ from fetchnode.api import capture_exception
8
+ from fetchnode.conf import get_client_settings
9
+ from fetchnode.query import record_query_observation, serialize_query_observations
10
+ from fetchnode.scope import add_breadcrumb, clear_scope, set_request
11
+ from fetchnode.sender import send_payload
12
+ from fetchnode.utils import utc_now_iso
13
+
14
+
15
+ _TRACEPARENT = re.compile(r"^[\da-f]{2}-([\da-f]{32})-[\da-f]{16}-[\da-f]{2}$", re.IGNORECASE)
16
+ _SAFE_CORRELATION_ID = re.compile(r"^[\w.-]{1,64}$")
17
+
18
+
19
+ def _request_correlation(request) -> tuple[str, str]:
20
+ traceparent = request.headers.get("Traceparent", "")
21
+ match = _TRACEPARENT.fullmatch(traceparent.strip())
22
+ supplied_trace = request.headers.get("X-FetchNode-Trace-ID", "").strip().lower()
23
+ trace_id = match.group(1).lower() if match else supplied_trace
24
+ if not re.fullmatch(r"[\da-f]{32}", trace_id):
25
+ trace_id = uuid.uuid4().hex
26
+ supplied_request_id = request.headers.get("X-Request-ID", "").strip()
27
+ request_id = supplied_request_id if _SAFE_CORRELATION_ID.fullmatch(supplied_request_id) else uuid.uuid4().hex
28
+ return trace_id, request_id
29
+
30
+
31
+ def _safe_header_id(request, name: str) -> str:
32
+ value = request.headers.get(name, "").strip()
33
+ return value if _SAFE_CORRELATION_ID.fullmatch(value) else ""
34
+
35
+
36
+ class ErrorCaptureMiddleware:
37
+ """Capture unhandled Django exceptions with request and database context."""
38
+
39
+ def __init__(self, get_response):
40
+ self.get_response = get_response
41
+ try:
42
+ from django.conf import settings
43
+ from fetchnode.api import track_event
44
+ setattr(settings, "fetchnode_track", track_event)
45
+ setattr(settings, "FETCHNODE_TRACK", track_event)
46
+ except Exception:
47
+ pass
48
+
49
+ def __call__(self, request):
50
+ config = get_client_settings()
51
+ if not config["ENABLED"]:
52
+ return self.get_response(request)
53
+ clear_scope()
54
+ set_request(request)
55
+ try:
56
+ return self._handle_request(request, config)
57
+ finally:
58
+ clear_scope()
59
+
60
+ def _handle_request(self, request, config):
61
+ started_request = time.monotonic()
62
+ trace_id, request_id = _request_correlation(request)
63
+ request._fetchnode_trace_id = trace_id
64
+ request._fetchnode_request_id = request_id
65
+ request._fetchnode_transaction_event_id = uuid.uuid4().hex
66
+ add_breadcrumb(
67
+ f"{request.method} {request.path}",
68
+ category="http.request",
69
+ data={"method": request.method, "path": request.path},
70
+ )
71
+ queries = []
72
+ query_stats = {"count": 0, "duration_ms": 0.0, "observations": {}}
73
+ request._fetchnode_queries = queries
74
+ request._fetchnode_query_stats = query_stats
75
+
76
+ def query_log(execute, sql, params, many, context):
77
+ started = time.monotonic()
78
+ try:
79
+ return execute(sql, params, many, context)
80
+ finally:
81
+ duration_ms = round((time.monotonic() - started) * 1000, 2)
82
+ query_stats["count"] += 1
83
+ query_stats["duration_ms"] += duration_ms
84
+ if len(queries) < 50:
85
+ query = {"duration_ms": duration_ms}
86
+ if config["CAPTURE_QUERY_TEXT"]:
87
+ query["sql"] = str(sql)[:20_000]
88
+ queries.append(query)
89
+ record_query_observation(
90
+ query_stats["observations"],
91
+ sql=str(sql),
92
+ duration_ms=duration_ms,
93
+ slow_threshold_ms=float(config["QUERY_SLOW_THRESHOLD_MS"]),
94
+ n_plus_one_threshold=int(config["N_PLUS_ONE_THRESHOLD"]),
95
+ capture_shape=bool(config["CAPTURE_QUERY_SHAPE"]),
96
+ maximum=int(config["MAX_QUERY_OBSERVATIONS"]),
97
+ )
98
+
99
+ try:
100
+ with connection.execute_wrapper(query_log):
101
+ response = self.get_response(request)
102
+ except Exception as exc:
103
+ if not getattr(request, "_fetchnode_exception_captured", False):
104
+ request._fetchnode_exception_captured = True
105
+ self.process_exception(request, exc)
106
+ self._send_transaction(request, 500, started_request, query_stats, config)
107
+ raise
108
+ if config["CAPTURE_TRANSACTIONS"] and not any(
109
+ request.path.startswith(prefix) for prefix in config["IGNORE_PATH_PREFIXES"]
110
+ ):
111
+ self._send_transaction(request, response.status_code, started_request, query_stats, config)
112
+ if config["TRACE_RESPONSE_HEADER"]:
113
+ response.setdefault("X-FetchNode-Trace-ID", trace_id)
114
+ response.setdefault("X-Request-ID", request_id)
115
+ return response
116
+
117
+ def _send_transaction(self, request, status_code, started_request, query_stats, config=None):
118
+ config = config or get_client_settings()
119
+ if not config["CAPTURE_TRANSACTIONS"] or any(
120
+ request.path.startswith(prefix) for prefix in config["IGNORE_PATH_PREFIXES"]
121
+ ):
122
+ return
123
+ resolver_match = getattr(request, "resolver_match", None)
124
+ auth_user = getattr(request, "user", None)
125
+ user_identifier = ""
126
+ if config["CAPTURE_USER_ID"] and auth_user is not None and getattr(auth_user, "is_authenticated", False):
127
+ user_identifier = str(getattr(auth_user, "pk", ""))[:255]
128
+ send_payload({
129
+ "event_id": request._fetchnode_transaction_event_id,
130
+ "type": "transaction",
131
+ "timestamp": utc_now_iso(),
132
+ "method": request.method,
133
+ "path": request.path,
134
+ "route": getattr(resolver_match, "route", "") or "",
135
+ "view_name": getattr(resolver_match, "view_name", "") or "",
136
+ "status_code": status_code,
137
+ "duration_ms": round((time.monotonic() - started_request) * 1000, 2),
138
+ "db_queries_count": query_stats["count"],
139
+ "db_time_ms": round(query_stats["duration_ms"], 2),
140
+ "query_observations": serialize_query_observations(query_stats["observations"]),
141
+ "environment": config["ENVIRONMENT"],
142
+ "release": config["RELEASE"],
143
+ "trace_id": request._fetchnode_trace_id,
144
+ "request_id": request._fetchnode_request_id,
145
+ "user_identifier": user_identifier,
146
+ "visitor_id": (
147
+ _safe_header_id(request, "X-FetchNode-Visitor-ID")
148
+ or getattr(request, "COOKIES", {}).get("_fn_vid", "")
149
+ ),
150
+ "session_id": (
151
+ _safe_header_id(request, "X-FetchNode-Session-ID")
152
+ or getattr(request, "COOKIES", {}).get("_fn_sid", "")
153
+ ),
154
+ })
155
+
156
+ def process_exception(self, request, exception):
157
+ config = get_client_settings()
158
+ if not config["ENABLED"] or any(request.path.startswith(prefix) for prefix in config["IGNORE_PATH_PREFIXES"]):
159
+ clear_scope()
160
+ return None
161
+ queries = getattr(request, "_fetchnode_queries", [])
162
+ query_stats = getattr(
163
+ request,
164
+ "_fetchnode_query_stats",
165
+ {"count": len(queries), "duration_ms": sum(query["duration_ms"] for query in queries)},
166
+ )
167
+ capture_exception(
168
+ exception,
169
+ request=request,
170
+ mechanism="django.middleware",
171
+ handled=False,
172
+ queries=queries,
173
+ contexts={
174
+ "database": {
175
+ "query_count": query_stats["count"],
176
+ "query_time_ms": round(query_stats["duration_ms"], 2),
177
+ "truncated": query_stats["count"] > len(queries),
178
+ }
179
+ },
180
+ )
181
+ request._fetchnode_exception_captured = True
182
+ clear_scope()
183
+ return None
184
+
185
+
186
+ # Primary and recommended middleware class name:
187
+ FetchNodeMiddleware = ErrorCaptureMiddleware
188
+