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 ADDED
@@ -0,0 +1,50 @@
1
+ default_app_config = "fetchnode.apps.FetchNodeConfig"
2
+
3
+ from fetchnode.version import SDK_VERSION
4
+
5
+ __version__ = SDK_VERSION
6
+
7
+ from fetchnode.api import capture_exception, capture_message, track, track_event
8
+ from fetchnode.capture import (
9
+ capture_context,
10
+ capture_django_task,
11
+ capture_exceptions,
12
+ capture_job,
13
+ capture_job_context,
14
+ capture_job_exception,
15
+ capture_management_command,
16
+ track_job_execution,
17
+ )
18
+ from fetchnode.excepthook import (
19
+ install_excepthook_instrumentation,
20
+ restore_excepthook,
21
+ uninstall_excepthook_instrumentation,
22
+ )
23
+ from fetchnode.scope import add_breadcrumb, clear_scope, set_context, set_tag, set_user
24
+ from fetchnode.sender import flush, transport_stats
25
+
26
+ __all__ = [
27
+ "__version__",
28
+ "capture_exception",
29
+ "capture_message",
30
+ "track",
31
+ "track_event",
32
+ "capture_exceptions",
33
+ "capture_context",
34
+ "capture_job_exception",
35
+ "capture_job_context",
36
+ "capture_job",
37
+ "capture_django_task",
38
+ "track_job_execution",
39
+ "capture_management_command",
40
+ "install_excepthook_instrumentation",
41
+ "uninstall_excepthook_instrumentation",
42
+ "restore_excepthook",
43
+ "add_breadcrumb",
44
+ "clear_scope",
45
+ "set_context",
46
+ "set_tag",
47
+ "set_user",
48
+ "flush",
49
+ "transport_stats",
50
+ ]
fetchnode/api.py ADDED
@@ -0,0 +1,309 @@
1
+ import hashlib
2
+ import uuid
3
+
4
+ from fetchnode.conf import get_client_settings
5
+ from fetchnode.scope import get_scope_data
6
+ from fetchnode.scrub import extract_headers, extract_request_data, scrub_mapping, scrub_query_string, scrub_value
7
+ from fetchnode.sender import send_payload
8
+ from fetchnode.utils import build_exception_values, get_server_name, runtime_context, utc_now_iso
9
+ from fetchnode.version import SDK_VERSION
10
+
11
+
12
+ SDK_CONTEXT = {"name": "fetchnode.python", "version": SDK_VERSION}
13
+
14
+
15
+ def _base_payload(*, level: str, logger_name: str = "", tags=None, contexts=None, breadcrumbs=None) -> dict:
16
+ config = get_client_settings()
17
+ scope = get_scope_data()
18
+ merged_tags = {**scope["tags"], **(tags or {})}
19
+ merged_contexts = {**runtime_context(), **scope["contexts"], **(contexts or {})}
20
+ return {
21
+ "event_id": uuid.uuid4().hex,
22
+ "timestamp": utc_now_iso(),
23
+ "level": level,
24
+ "logger": logger_name,
25
+ "environment": config["ENVIRONMENT"],
26
+ "release": config["RELEASE"],
27
+ "server_name": get_server_name(),
28
+ "sdk": SDK_CONTEXT,
29
+ "tags": scrub_mapping(merged_tags, sensitive_keys=config["SENSITIVE_KEYS"]),
30
+ "contexts": scrub_value(merged_contexts, sensitive_keys=config["SENSITIVE_KEYS"]),
31
+ "breadcrumbs": scrub_value([*scope["breadcrumbs"], *(breadcrumbs or [])][-100:], sensitive_keys=config["SENSITIVE_KEYS"]),
32
+ "request": {},
33
+ "user": scrub_mapping(scope["user"], sensitive_keys=config["SENSITIVE_KEYS"]),
34
+ }
35
+
36
+
37
+ def _request_context(request, config: dict) -> dict:
38
+ resolver_match = getattr(request, "resolver_match", None)
39
+ safe_query = ""
40
+ if config["CAPTURE_QUERY_STRING"]:
41
+ safe_query = scrub_query_string(
42
+ request.META.get("QUERY_STRING", ""),
43
+ sensitive_keys=config["SENSITIVE_KEYS"],
44
+ )
45
+ try:
46
+ url = request.build_absolute_uri(request.path)
47
+ except Exception:
48
+ url = request.path
49
+ if safe_query:
50
+ url = f"{url}?{safe_query}"
51
+ return {
52
+ "method": request.method,
53
+ "path": request.path,
54
+ "url": url,
55
+ "route": getattr(resolver_match, "route", "") or "",
56
+ "view_name": getattr(resolver_match, "view_name", "") or "",
57
+ "query_string": safe_query,
58
+ "ip_address": (
59
+ request.META.get("HTTP_X_FORWARDED_FOR", "").split(",", 1)[0].strip()
60
+ or request.META.get("REMOTE_ADDR", "")
61
+ ) if config["CAPTURE_IP_ADDRESS"] else "",
62
+ "headers": extract_headers(
63
+ request,
64
+ capture_headers=config["CAPTURE_HEADERS"],
65
+ sensitive_headers=config["SENSITIVE_HEADERS"],
66
+ ),
67
+ "data": extract_request_data(
68
+ request,
69
+ capture_body=config["CAPTURE_BODY"],
70
+ sensitive_keys=config["SENSITIVE_KEYS"],
71
+ max_body_length=config["MAX_BODY_LENGTH"],
72
+ ),
73
+ }
74
+
75
+
76
+ def _request_user(request, config: dict) -> dict:
77
+ auth_user = getattr(request, "user", None)
78
+ if auth_user is None or not getattr(auth_user, "is_authenticated", False):
79
+ return {}
80
+ user = {}
81
+ if config["CAPTURE_USER_ID"]:
82
+ user["id"] = getattr(auth_user, "pk", "")
83
+ if config["CAPTURE_USERNAME"]:
84
+ user["username"] = getattr(auth_user, "get_username", lambda: "")()
85
+ if config["CAPTURE_USER_EMAIL"]:
86
+ user["email"] = getattr(auth_user, "email", "")
87
+ return user
88
+
89
+
90
+ def _attach_request_and_user(payload: dict, *, request=None, user=None) -> None:
91
+ config = get_client_settings()
92
+ if request is not None:
93
+ payload["request"] = _request_context(request, config)
94
+ request_id = getattr(request, "_fetchnode_request_id", "") or request.headers.get("X-Request-ID", "")
95
+ traceparent = request.headers.get("Traceparent", "")
96
+ trace_id = getattr(request, "_fetchnode_trace_id", "")
97
+ if request_id:
98
+ payload["tags"]["request_id"] = request_id[:255]
99
+ payload["request_id"] = request_id[:64]
100
+ if trace_id:
101
+ payload["trace_id"] = trace_id[:32]
102
+ if traceparent or trace_id:
103
+ payload["contexts"]["trace"] = {
104
+ "traceparent": traceparent[:255],
105
+ "trace_id": trace_id[:32],
106
+ "transaction_event_id": getattr(request, "_fetchnode_transaction_event_id", "")[:32],
107
+ }
108
+ payload["user"] = scrub_mapping(_request_user(request, config), sensitive_keys=config["SENSITIVE_KEYS"])
109
+ if user:
110
+ payload["user"] = scrub_mapping(user, sensitive_keys=config["SENSITIVE_KEYS"])
111
+
112
+
113
+ def build_exception_payload(
114
+ exc: BaseException,
115
+ request=None,
116
+ *,
117
+ user=None,
118
+ tags=None,
119
+ contexts=None,
120
+ breadcrumbs=None,
121
+ extra=None,
122
+ queries=None,
123
+ logger_name="",
124
+ mechanism="generic",
125
+ handled=True,
126
+ ) -> dict:
127
+ payload = _base_payload(
128
+ level="error",
129
+ logger_name=logger_name,
130
+ tags=tags,
131
+ contexts=contexts,
132
+ breadcrumbs=breadcrumbs,
133
+ )
134
+ values = build_exception_values(exc, mechanism=mechanism, handled=handled)
135
+ primary = values[-1]
136
+ payload["exception"] = {
137
+ "type": primary["type"],
138
+ "message": primary["message"],
139
+ "frames": primary["frames"],
140
+ "values": values,
141
+ }
142
+ if extra:
143
+ payload["extra"] = scrub_value(extra, sensitive_keys=get_client_settings()["SENSITIVE_KEYS"])
144
+ if queries:
145
+ payload["queries"] = scrub_value(list(queries)[:100], sensitive_keys=get_client_settings()["SENSITIVE_KEYS"])
146
+ _attach_request_and_user(payload, request=request, user=user)
147
+ return payload
148
+
149
+
150
+ def capture_exception(
151
+ exc: BaseException,
152
+ request=None,
153
+ *,
154
+ user=None,
155
+ tags=None,
156
+ contexts=None,
157
+ breadcrumbs=None,
158
+ extra=None,
159
+ queries=None,
160
+ logger_name="",
161
+ mechanism="generic",
162
+ handled=True,
163
+ ) -> str:
164
+ payload = build_exception_payload(
165
+ exc,
166
+ request=request,
167
+ user=user,
168
+ tags=tags,
169
+ contexts=contexts,
170
+ breadcrumbs=breadcrumbs,
171
+ extra=extra,
172
+ queries=queries,
173
+ logger_name=logger_name,
174
+ mechanism=mechanism,
175
+ handled=handled,
176
+ )
177
+ send_payload(payload)
178
+ return payload["event_id"]
179
+
180
+
181
+ def capture_message(
182
+ message: str,
183
+ *,
184
+ level: str = "error",
185
+ request=None,
186
+ user=None,
187
+ tags=None,
188
+ contexts=None,
189
+ breadcrumbs=None,
190
+ logger_name="",
191
+ ) -> str:
192
+ payload = _base_payload(
193
+ level=level,
194
+ logger_name=logger_name,
195
+ tags=tags,
196
+ contexts=contexts,
197
+ breadcrumbs=breadcrumbs,
198
+ )
199
+ payload["exception"] = {
200
+ "type": "Message",
201
+ "message": str(message),
202
+ "frames": [],
203
+ "values": [{
204
+ "type": "Message",
205
+ "module": "",
206
+ "message": str(message),
207
+ "frames": [],
208
+ "mechanism": {"type": "message", "handled": True},
209
+ }],
210
+ }
211
+ _attach_request_and_user(payload, request=request, user=user)
212
+ send_payload(payload)
213
+ return payload["event_id"]
214
+
215
+
216
+ def track_event(
217
+ name: str = "",
218
+ properties: dict | None = None,
219
+ *,
220
+ user_identifier: str = "",
221
+ visitor_id: str = "",
222
+ session_id: str = "",
223
+ path: str = "",
224
+ environment: str = "",
225
+ conversion: dict | bool | str | None = None,
226
+ idempotency_key: str = "",
227
+ request=None,
228
+ event: str = "",
229
+ user_id: str = "",
230
+ distinct_id: str = "",
231
+ payload: dict | None = None,
232
+ ) -> str:
233
+ """Track a server-side product milestone such as signup_completed or lead_submitted.
234
+
235
+ Usage:
236
+ fetchnode.track("lead_submitted", request=request, idempotency_key=str(lead.id))
237
+ fetchnode.track("signup_completed", conversion=True, request=request)
238
+ """
239
+ event_name = str(name or event or "")
240
+ if not event_name:
241
+ raise ValueError("Event name is required for track_event.")
242
+
243
+ config = get_client_settings()
244
+ stable_key = str(idempotency_key or "")
245
+ if len(stable_key) > 512:
246
+ raise ValueError("idempotency_key must contain at most 512 characters.")
247
+ event_id = (
248
+ hashlib.sha256(f"{event_name}\0{stable_key}".encode("utf-8")).hexdigest()[:32]
249
+ if stable_key
250
+ else uuid.uuid4().hex
251
+ )
252
+
253
+ if request is not None:
254
+ path = path or getattr(request, "path", "")
255
+ if not visitor_id:
256
+ cookies = getattr(request, "COOKIES", {}) or {}
257
+ headers = getattr(request, "headers", {}) or {}
258
+ visitor_id = (
259
+ headers.get("X-FetchNode-Visitor-ID", "")
260
+ or cookies.get("_fn_vid", "")
261
+ or str(distinct_id or "")
262
+ )
263
+ if not session_id:
264
+ cookies = getattr(request, "COOKIES", {}) or {}
265
+ headers = getattr(request, "headers", {}) or {}
266
+ session_id = (
267
+ headers.get("X-FetchNode-Session-ID", "")
268
+ or cookies.get("_fn_sid", "")
269
+ )
270
+ if not user_identifier:
271
+ req_user = getattr(request, "user", None)
272
+ if req_user and getattr(req_user, "is_authenticated", False):
273
+ user_identifier = str(getattr(req_user, "pk", ""))
274
+ elif user_id:
275
+ user_identifier = str(user_id)
276
+
277
+ event_properties = scrub_value(properties or payload or {}, sensitive_keys=config["SENSITIVE_KEYS"])
278
+ if conversion is not None:
279
+ if conversion is True:
280
+ conv_obj = {"name": event_name}
281
+ elif isinstance(conversion, str):
282
+ conv_obj = {"name": conversion}
283
+ elif isinstance(conversion, dict):
284
+ conv_obj = conversion
285
+ else:
286
+ conv_obj = {"name": event_name}
287
+ event_properties["_fetchnode_conversion"] = scrub_value(
288
+ conv_obj,
289
+ sensitive_keys=config["SENSITIVE_KEYS"],
290
+ )
291
+ send_payload({
292
+ "event_id": event_id,
293
+ "type": "custom",
294
+ "name": str(event_name),
295
+ "payload": event_properties,
296
+ "user_identifier": str(user_identifier),
297
+ "visitor_id": str(visitor_id),
298
+ "session_id": str(session_id),
299
+ "path": str(path),
300
+ "timestamp": utc_now_iso(),
301
+ "environment": environment or config["ENVIRONMENT"],
302
+ "sdk": SDK_CONTEXT,
303
+ })
304
+ return event_id
305
+
306
+
307
+ # Intuitive shortcut alias:
308
+ track = track_event
309
+
fetchnode/apps.py ADDED
@@ -0,0 +1,36 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class FetchNodeConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "fetchnode"
7
+ verbose_name = "FetchNode"
8
+
9
+ def ready(self):
10
+ from fetchnode.conf import get_client_settings
11
+ from fetchnode.api import track_event
12
+
13
+ # Ensure settings.fetchnode_track is available if needed by legacy code
14
+ try:
15
+ from django.conf import settings
16
+ setattr(settings, "fetchnode_track", track_event)
17
+ setattr(settings, "FETCHNODE_TRACK", track_event)
18
+ except Exception:
19
+ pass
20
+
21
+ config = get_client_settings()
22
+ if config["ENABLED"] and config["INSTRUMENT_LOGGING"]:
23
+ from fetchnode.handlers import install_logging_instrumentation
24
+ install_logging_instrumentation()
25
+
26
+ if config["INSTRUMENT_CELERY"]:
27
+ from fetchnode.celery import install_celery_instrumentation
28
+ install_celery_instrumentation()
29
+
30
+ if config["ENABLED"] and config["INSTRUMENT_EXCEPTHOOK"]:
31
+ from fetchnode.excepthook import install_excepthook_instrumentation
32
+ install_excepthook_instrumentation()
33
+
34
+
35
+ # Backwards compatibility alias
36
+ FetchNodeClientConfig = FetchNodeConfig
fetchnode/capture.py ADDED
@@ -0,0 +1,202 @@
1
+ from contextlib import contextmanager
2
+ from functools import wraps
3
+ import time
4
+ import uuid
5
+
6
+ from fetchnode.api import capture_exception
7
+ from fetchnode.conf import get_client_settings
8
+ from fetchnode.scrub import scrub_value
9
+ from fetchnode.sender import send_payload
10
+ from fetchnode.utils import utc_now_iso
11
+
12
+
13
+ def track_job_execution(
14
+ *,
15
+ task_name: str,
16
+ status: str,
17
+ duration_ms: float = 0,
18
+ queue_latency_ms: float = 0,
19
+ retry_count: int = 0,
20
+ task_id: str = "",
21
+ queue: str = "",
22
+ runtime: str = "generic",
23
+ trace_id: str = "",
24
+ parent_trace_id: str = "",
25
+ properties=None,
26
+ ) -> str:
27
+ config = get_client_settings()
28
+ event_id = uuid.uuid4().hex
29
+ if not config["CAPTURE_JOBS"]:
30
+ return event_id
31
+ send_payload({
32
+ "event_id": event_id,
33
+ "type": "job",
34
+ "timestamp": utc_now_iso(),
35
+ "task_id": str(task_id),
36
+ "task_name": str(task_name),
37
+ "queue": str(queue),
38
+ "runtime": str(runtime),
39
+ "status": str(status),
40
+ "duration_ms": round(max(0, float(duration_ms)), 2),
41
+ "queue_latency_ms": round(max(0, float(queue_latency_ms)), 2),
42
+ "retry_count": max(0, int(retry_count)),
43
+ "trace_id": trace_id or uuid.uuid4().hex,
44
+ "parent_trace_id": parent_trace_id,
45
+ "environment": config["ENVIRONMENT"],
46
+ "release": config["RELEASE"],
47
+ "payload": scrub_value(properties or {}, sensitive_keys=config["SENSITIVE_KEYS"]),
48
+ })
49
+ return event_id
50
+
51
+
52
+ def capture_job_exception(
53
+ exc: Exception, *, job_name: str, queue: str = "", tags=None, logger_name="", record_execution=True
54
+ ) -> None:
55
+ merged_tags = {"capture_mode": "job", "job_name": job_name}
56
+ if queue:
57
+ merged_tags["queue"] = queue
58
+ if tags:
59
+ merged_tags.update(tags)
60
+ capture_exception(exc, tags=merged_tags, logger_name=logger_name or f"jobs.{job_name}")
61
+ if record_execution:
62
+ track_job_execution(task_name=job_name, queue=queue, status="failed")
63
+
64
+
65
+ def capture_exceptions(*, tags=None, logger_name="", reraise=True):
66
+ def decorator(func):
67
+ @wraps(func)
68
+ def wrapper(*args, **kwargs):
69
+ try:
70
+ return func(*args, **kwargs)
71
+ except Exception as exc:
72
+ merged_tags = {"capture_mode": "decorator", "callable": func.__name__}
73
+ if tags:
74
+ merged_tags.update(tags)
75
+ capture_exception(exc, tags=merged_tags, logger_name=logger_name or func.__module__)
76
+ if reraise:
77
+ raise
78
+ return None
79
+
80
+ return wrapper
81
+
82
+ return decorator
83
+
84
+
85
+ def capture_job(*, job_name: str, queue: str = "", tags=None, logger_name="", reraise=True):
86
+ def decorator(func):
87
+ @wraps(func)
88
+ def wrapper(*args, **kwargs):
89
+ started = time.monotonic()
90
+ trace_id = uuid.uuid4().hex
91
+ try:
92
+ result = func(*args, **kwargs)
93
+ except Exception as exc:
94
+ merged_tags = {"capture_mode": "job_decorator", "callable": func.__name__}
95
+ if tags:
96
+ merged_tags.update(tags)
97
+ capture_job_exception(
98
+ exc,
99
+ job_name=job_name,
100
+ queue=queue,
101
+ tags=merged_tags,
102
+ logger_name=logger_name or func.__module__,
103
+ record_execution=False,
104
+ )
105
+ track_job_execution(
106
+ task_name=job_name,
107
+ queue=queue,
108
+ status="failed",
109
+ duration_ms=(time.monotonic() - started) * 1000,
110
+ runtime="decorator",
111
+ trace_id=trace_id,
112
+ properties=merged_tags,
113
+ )
114
+ if reraise:
115
+ raise
116
+ return None
117
+ track_job_execution(
118
+ task_name=job_name,
119
+ queue=queue,
120
+ status="succeeded",
121
+ duration_ms=(time.monotonic() - started) * 1000,
122
+ runtime="decorator",
123
+ trace_id=trace_id,
124
+ properties=tags,
125
+ )
126
+ return result
127
+
128
+ return wrapper
129
+
130
+ return decorator
131
+
132
+
133
+ def capture_management_command(*, command_name: str, tags=None, logger_name="management", reraise=True):
134
+ merged_tags = {"runtime": "management_command", "command": command_name}
135
+ if tags:
136
+ merged_tags.update(tags)
137
+ return capture_context(tags=merged_tags, logger_name=logger_name, reraise=reraise)
138
+
139
+
140
+ def capture_django_task(*, task_name: str, queue: str = "", tags=None, reraise=True):
141
+ """Instrument a Django 6 Tasks backend callable without depending on a backend."""
142
+ return capture_job(
143
+ job_name=task_name,
144
+ queue=queue,
145
+ tags={"runtime": "django.tasks", **(tags or {})},
146
+ logger_name=f"django.tasks.{task_name}",
147
+ reraise=reraise,
148
+ )
149
+
150
+
151
+ @contextmanager
152
+ def capture_job_context(*, job_name: str, queue: str = "", tags=None, logger_name="", reraise=True):
153
+ started = time.monotonic()
154
+ trace_id = uuid.uuid4().hex
155
+ try:
156
+ yield
157
+ except Exception as exc:
158
+ merged_tags = {"capture_mode": "job_context"}
159
+ if tags:
160
+ merged_tags.update(tags)
161
+ capture_job_exception(
162
+ exc,
163
+ job_name=job_name,
164
+ queue=queue,
165
+ tags=merged_tags,
166
+ logger_name=logger_name or f"jobs.{job_name}",
167
+ record_execution=False,
168
+ )
169
+ track_job_execution(
170
+ task_name=job_name,
171
+ queue=queue,
172
+ status="failed",
173
+ duration_ms=(time.monotonic() - started) * 1000,
174
+ runtime="contextmanager",
175
+ trace_id=trace_id,
176
+ properties=merged_tags,
177
+ )
178
+ if reraise:
179
+ raise
180
+ else:
181
+ track_job_execution(
182
+ task_name=job_name,
183
+ queue=queue,
184
+ status="succeeded",
185
+ duration_ms=(time.monotonic() - started) * 1000,
186
+ runtime="contextmanager",
187
+ trace_id=trace_id,
188
+ properties=tags,
189
+ )
190
+
191
+
192
+ @contextmanager
193
+ def capture_context(*, tags=None, logger_name="", reraise=True):
194
+ try:
195
+ yield
196
+ except Exception as exc:
197
+ merged_tags = {"capture_mode": "contextmanager"}
198
+ if tags:
199
+ merged_tags.update(tags)
200
+ capture_exception(exc, tags=merged_tags, logger_name=logger_name)
201
+ if reraise:
202
+ raise