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/__init__.py +50 -0
- fetchnode/api.py +309 -0
- fetchnode/apps.py +36 -0
- fetchnode/capture.py +202 -0
- fetchnode/celery.py +138 -0
- fetchnode/conf.py +127 -0
- fetchnode/demo.py +3 -0
- fetchnode/excepthook.py +83 -0
- fetchnode/handlers.py +76 -0
- fetchnode/middleware.py +188 -0
- fetchnode/query.py +90 -0
- fetchnode/scope.py +81 -0
- fetchnode/scrub.py +87 -0
- fetchnode/sender.py +241 -0
- fetchnode/utils.py +78 -0
- fetchnode/version.py +3 -0
- fetchnode-0.4.0.dist-info/METADATA +166 -0
- fetchnode-0.4.0.dist-info/RECORD +36 -0
- fetchnode-0.4.0.dist-info/WHEEL +4 -0
- fetchnode-0.4.0.dist-info/licenses/LICENSE +21 -0
- fetchnode_client/__init__.py +4 -0
- fetchnode_client/api.py +1 -0
- fetchnode_client/apps.py +2 -0
- fetchnode_client/capture.py +1 -0
- fetchnode_client/celery.py +1 -0
- fetchnode_client/conf.py +1 -0
- fetchnode_client/demo.py +1 -0
- fetchnode_client/excepthook.py +1 -0
- fetchnode_client/handlers.py +1 -0
- fetchnode_client/middleware.py +1 -0
- fetchnode_client/query.py +1 -0
- fetchnode_client/scope.py +1 -0
- fetchnode_client/scrub.py +1 -0
- fetchnode_client/sender.py +1 -0
- fetchnode_client/utils.py +1 -0
- fetchnode_client/version.py +1 -0
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
fetchnode/excepthook.py
ADDED
|
@@ -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
|
fetchnode/middleware.py
ADDED
|
@@ -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
|
+
|