Setup guide
Add FetchNode to your Django site
Use one project per website. Paste the browser snippet into the base template, paste the Django snippet at the bottom of settings.py, deploy or restart the app, then verify all four signals in the selected project.
Create a project key first
FetchNode needs a project key before it can accept errors, traffic, and pageviews from this site.
Use your project key
Replace PUBLIC_PROJECT_KEY before deploying. Once you create a project key, this page fills it in for you.
Step 1
Browser snippet
Paste this once, immediately before </head>, in the base template rendered by every public page. It records browser errors, pageviews, sessions, acquisition sources and product events.
Identify users and record conversions
Call these after login or when a meaningful product milestone happens:
FetchNode("consent", {analytics: true}); // Only after analytics consent.
FetchNode("identify", {id: "user-42"});
FetchNode("track", "signup_completed", {plan: "free"});
Add signup_completed as a key event in Project Settings to calculate its unique-user conversion ratio. Call FetchNode("reset") on logout so shared browsers never remain linked to the previous user.
<script>
window.FetchNodeQueue = window.FetchNodeQueue || [];
window.FetchNode = window.FetchNode || function () {
window.FetchNodeQueue.push(arguments);
};
FetchNode("init", {
projectKey: "PROJECT_KEY_HIDDEN",
endpoint: "https://fetch-node.com/api/events/ingest/",
enabled: {{ fetchnode_browser.enabled|yesno:"true,false" }},
environment: "{{ fetchnode_browser.environment|escapejs }}",
release: "{{ fetchnode_browser.release|escapejs }}",
trackPageviews: true
});
if (typeof window.FETCHNODE_ANALYTICS_CONSENT === 'boolean') {
FetchNode("consent", {analytics: window.FETCHNODE_ANALYTICS_CONSENT});
}
</script>
<script async src="https://fetch-node.com/widget/v1/widget.min.js?v=20260802-2" crossorigin="anonymous"></script>
window.FETCHNODE_ANALYTICS_CONSENT from the existing cookie choice before this snippet runs. When a visitor accepts later, call FetchNode("consent", {analytics: true}); on withdrawal call it with false. Add FetchNode analytics to the cookie banner and privacy policy. Browser error tracking remains separate so you can apply the legal basis chosen for operational monitoring.
Step 2
Django settings.py snippet
Paste the complete block at the very bottom of the production settings.py. It captures server exceptions, application request duration and database query counts. It uses only Django and the Python standard library—no package installation or Django app registration.
settings.py
Paste this block at the bottom of the file.
# FetchNode — paste everything in this block at the bottom of settings.py.
# No pip install, no downloaded file, no dependency: only the Python standard library + Django.
import os
import sys
def _fetchnode_env_bool(name, default=False):
return os.getenv(name, '1' if default else '0').lower() in {'1', 'true', 'yes', 'on'}
FETCHNODE = {
'ENABLED': _fetchnode_env_bool('FETCHNODE_ENABLED', False) and not any(arg == 'test' or 'pytest' in arg for arg in sys.argv),
'DSN': 'https://fetch-node.com/api/events/ingest/',
'KEY': 'PROJECT_KEY_HIDDEN',
'ENVIRONMENT': os.getenv('FETCHNODE_ENVIRONMENT', 'development'),
'RELEASE': os.getenv('FETCHNODE_RELEASE', ''), # Deployment version or git SHA.
'TIMEOUT': 5,
'MAX_RETRIES': 3,
'BATCH_SIZE': 10,
'COMPRESS_PAYLOADS': True,
'COMPRESSION_THRESHOLD_BYTES': 1024,
'DISK_BUFFER_PATH': os.getenv('FETCHNODE_BUFFER_PATH', ''), # Optional durable retry spool.
'MAX_BUFFERED_EVENTS': 1000,
'TRANSACTION_SAMPLE_RATE': 1.0, # Errors, 5xx responses and latency outliers remain at 100%.
'KEEP_TRANSACTION_OUTLIER_MS': 1000,
'CAPTURE_TRANSACTIONS': True,
'TRANSACTION_EXCLUDE_PREFIXES': ('/widget/', '/static/', '/media/', '/favicon.ico', '/robots.txt', '/llms.txt', '/sitemap', '/health/', '/ready/'),
'CAPTURE_404': False, # Avoid public scanner noise; set True only when needed.
'CAPTURE_HEADERS': False,
'CAPTURE_BODY': False, # Safer privacy default; opt in deliberately.
'CAPTURE_IP_ADDRESS': False,
'CAPTURE_QUERY_STRING': False,
'CAPTURE_QUERY_TEXT': False,
'CAPTURE_QUERY_SHAPE': True, # Normalized SQL structure only; never query parameters.
'QUERY_SLOW_THRESHOLD_MS': 100,
'N_PLUS_ONE_THRESHOLD': 5,
'MAX_QUERY_OBSERVATIONS': 50,
'TRACE_RESPONSE_HEADER': True,
'CAPTURE_USER_ID': True, # Never sends username or email.
'MAX_BODY_LENGTH': 4000,
}
def fetchnode_template_context(request):
return {'fetchnode_browser': {
'enabled': FETCHNODE['ENABLED'],
'environment': FETCHNODE['ENVIRONMENT'],
'release': FETCHNODE['RELEASE'],
}}
_fetchnode_context_processor = f'{__name__}.fetchnode_template_context'
for _fetchnode_template in TEMPLATES:
_fetchnode_options = _fetchnode_template.setdefault('OPTIONS', {})
_fetchnode_processors = list(_fetchnode_options.get('context_processors', []))
if _fetchnode_context_processor not in _fetchnode_processors:
_fetchnode_processors.append(_fetchnode_context_processor)
_fetchnode_options['context_processors'] = _fetchnode_processors
# FetchNodeMiddleware is defined below in this same settings.py snippet.
# Keep it first so it sees every request, exception, 404, and slow query.
if FETCHNODE['ENABLED'] and f'{__name__}.FetchNodeMiddleware' not in MIDDLEWARE:
MIDDLEWARE = [f'{__name__}.FetchNodeMiddleware', *MIDDLEWARE]
# Capture handled application errors (logger.error/logger.exception), not only
# exceptions that escape a Django view. Keep existing logging configuration intact.
LOGGING = globals().get('LOGGING', {})
LOGGING.setdefault('version', 1)
LOGGING.setdefault('disable_existing_loggers', False)
_fetchnode_handlers = LOGGING.setdefault('handlers', {})
_fetchnode_handlers.setdefault('fetchnode', {
'level': 'ERROR',
'class': f'{__name__}.FetchNodeHandler',
})
_fetchnode_root = LOGGING.setdefault('root', {})
_fetchnode_root_handlers = list(_fetchnode_root.get('handlers', []))
if 'fetchnode' not in _fetchnode_root_handlers:
_fetchnode_root_handlers.append('fetchnode')
_fetchnode_root['handlers'] = _fetchnode_root_handlers
_fetchnode_root.setdefault('level', 'WARNING')
for _fetchnode_logger in LOGGING.setdefault('loggers', {}).values():
if not _fetchnode_logger.get('propagate', True):
_fetchnode_logger_handlers = list(_fetchnode_logger.get('handlers', []))
if 'fetchnode' not in _fetchnode_logger_handlers:
_fetchnode_logger_handlers.append('fetchnode')
_fetchnode_logger['handlers'] = _fetchnode_logger_handlers
"""
FetchNode Django Snippet
========================================
Paste this snippet at the very bottom of your Django settings.py file.
It captures unhandled exceptions, ERROR+ application logs, and request traffic.
Set CAPTURE_404 to True only when you intentionally want missing routes as
error events.
"""
import json
import gzip
import hashlib
import linecache
import logging
import platform
import os
import queue
import re
import socket
import threading
import time
import traceback
import uuid
import urllib.request
from contextvars import ContextVar
from datetime import datetime, timezone
from urllib.parse import parse_qsl, urlencode
from django.conf import settings
from django.http import HttpRequest
logger = logging.getLogger("fetchnode.middleware")
_event_queue = queue.Queue(maxsize=500)
_sender_lock = threading.Lock()
_sender_started = False
_transport_stats = {"queued": 0, "delivered": 0, "buffered": 0, "dropped": 0, "replayed": 0}
_active_request = ContextVar("fetchnode_active_request", default=None)
_FILTERED = "[Filtered]"
_DEFAULT_SENSITIVE_KEYS = {
"password", "passwd", "secret", "token", "api_key", "access_token",
"refresh_token", "authorization", "cookie", "csrfmiddlewaretoken", "x_api_key",
}
_DEFAULT_TRANSACTION_EXCLUDE_PREFIXES = (
"/widget/", "/static/", "/media/", "/favicon.ico", "/robots.txt", "/sitemap", "/health/", "/ready/",
)
_TRACEPARENT = re.compile(r"^[\da-f]{2}-([\da-f]{32})-[\da-f]{16}-[\da-f]{2}$", re.IGNORECASE)
_SAFE_ID = re.compile(r"^[\w.-]{1,64}$")
_STRING_LITERAL = re.compile(r"'(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"")
_NUMBER_LITERAL = re.compile(r"(?<![\w.])-?\d+(?:\.\d+)?(?![\w.])")
_TABLE = re.compile(r"\b(?:from|join|update|into)\s+(?:only\s+)?[\"`\[]?([\w.]+)", re.IGNORECASE)
def _cfg(key: str, default=""):
"""Read a value from settings.FETCHNODE with a default."""
return getattr(settings, "FETCHNODE", {}).get(key, default)
def _correlation(request: HttpRequest) -> tuple[str, str]:
match = _TRACEPARENT.fullmatch(request.headers.get("Traceparent", "").strip())
supplied_trace = request.headers.get("X-FetchNode-Trace-ID", "").strip().lower()
trace_id = match.group(1).lower() if match else supplied_trace
if not re.fullmatch(r"[\da-f]{32}", trace_id):
trace_id = uuid.uuid4().hex
supplied_request = request.headers.get("X-Request-ID", "").strip()
request_id = supplied_request if _SAFE_ID.fullmatch(supplied_request) else uuid.uuid4().hex
return trace_id, request_id
def _safe_header_id(request: HttpRequest, name: str) -> str:
value = request.headers.get(name, "").strip()
return value if _SAFE_ID.fullmatch(value) else ""
def _query_identity(sql: str) -> tuple[str, str, str, str]:
normalized = _STRING_LITERAL.sub("?", str(sql or ""))
normalized = _NUMBER_LITERAL.sub("?", normalized)
normalized = re.sub(r"(?:%s|\?|:\w+)(?:\s*,\s*(?:%s|\?|:\w+))+", "?", normalized, flags=re.IGNORECASE)
normalized = re.sub(r"\s+", " ", normalized).strip()[:20000]
operation_match = re.match(r"\s*([a-z]+)", normalized, re.IGNORECASE)
operation = operation_match.group(1).upper()[:16] if operation_match else "QUERY"
table_match = _TABLE.search(normalized)
table = table_match.group(1)[:255] if table_match else ""
return hashlib.sha256(normalized.lower().encode()).hexdigest(), operation, table, normalized
def _query_location() -> str:
for frame in reversed(traceback.extract_stack(limit=30)[:-1]):
filename = frame.filename.replace("\\", "/")
if any(marker in filename for marker in ("/django/", "/site-packages/", "django_middleware.py")):
continue
return f"{frame.filename}:{frame.lineno} in {frame.name}"[:1024]
return ""
def _observe_query(observations: dict, sql: str, duration_ms: float) -> None:
fingerprint, operation, table, normalized = _query_identity(sql)
location = _query_location()
key = (fingerprint, location)
item = observations.get(key)
if item is None:
if len(observations) >= int(_cfg("MAX_QUERY_OBSERVATIONS", 50)):
return
item = {
"fingerprint": fingerprint, "operation": operation, "table": table,
"normalized_sql": normalized if _cfg("CAPTURE_QUERY_SHAPE", True) else "",
"stack_location": location, "count": 0, "total_duration_ms": 0.0, "max_duration_ms": 0.0,
}
observations[key] = item
item["count"] += 1
item["total_duration_ms"] = round(item["total_duration_ms"] + duration_ms, 2)
item["max_duration_ms"] = round(max(item["max_duration_ms"], duration_ms), 2)
item["is_slow"] = item["max_duration_ms"] >= float(_cfg("QUERY_SLOW_THRESHOLD_MS", 100))
item["is_duplicate"] = item["count"] > 1
item["suspected_n_plus_one"] = item["count"] >= int(_cfg("N_PLUS_ONE_THRESHOLD", 5)) and operation == "SELECT"
def _should_capture_transaction(request: HttpRequest) -> bool:
if not _cfg("ENABLED", False) or not _cfg("CAPTURE_TRANSACTIONS", True):
return False
prefixes = _cfg("TRANSACTION_EXCLUDE_PREFIXES", _DEFAULT_TRANSACTION_EXCLUDE_PREFIXES)
if isinstance(prefixes, str):
prefixes = [prefix.strip() for prefix in prefixes.split(",") if prefix.strip()]
return not any(request.path.startswith(str(prefix)) for prefix in prefixes)
def _send(payload: dict) -> None:
"""Queue telemetry without creating an unbounded thread per request."""
dsn, key = _cfg("DSN"), _cfg("KEY")
if not _cfg("ENABLED", False) or not dsn or not key:
return
if payload.get("type") == "transaction" and int(payload.get("status_code") or 0) < 500:
duration = float(payload.get("duration_ms") or 0)
rate = min(1.0, max(0.0, float(_cfg("TRANSACTION_SAMPLE_RATE", 1.0))))
if duration < float(_cfg("KEEP_TRANSACTION_OUTLIER_MS", 1000)) and rate < 1:
stable_id = str(payload.get("event_id") or uuid.uuid4().hex)
bucket = int(hashlib.sha256(stable_id.encode()).hexdigest()[:8], 16) / 0xFFFFFFFF
if bucket >= rate:
return
global _sender_started
with _sender_lock:
if not _sender_started:
threading.Thread(target=_sender_worker, daemon=True, name="fetchnode-sender").start()
_sender_started = True
try:
_event_queue.put_nowait((dsn, key, payload))
_transport_stats["queued"] += 1
except queue.Full:
if not _buffer_event(payload):
_transport_stats["dropped"] += 1
def fetchnode_transport_stats() -> dict:
"""Return local sender health for readiness checks and diagnostics."""
return {**_transport_stats, "queue_size": _event_queue.qsize(), "unfinished": _event_queue.unfinished_tasks}
def _buffer_event(payload: dict) -> bool:
directory = str(_cfg("DISK_BUFFER_PATH", "") or "").strip()
if not directory:
return False
try:
os.makedirs(directory, mode=0o700, exist_ok=True)
files = sorted(name for name in os.listdir(directory) if name.endswith(".json"))
if len(files) >= int(_cfg("MAX_BUFFERED_EVENTS", 1000)):
return False
path = os.path.join(directory, f"{time.time_ns()}-{uuid.uuid4().hex}.json")
temporary = path + ".tmp"
with open(temporary, "x", encoding="utf-8") as handle:
json.dump(payload, handle, default=str, separators=(",", ":"))
os.chmod(temporary, 0o600)
os.replace(temporary, path)
_transport_stats["buffered"] += 1
return True
except Exception:
return False
def _buffered_events(limit: int):
directory = str(_cfg("DISK_BUFFER_PATH", "") or "").strip()
if not directory or not os.path.isdir(directory):
return []
values = []
for name in sorted(item for item in os.listdir(directory) if item.endswith(".json"))[:limit]:
path = os.path.join(directory, name)
try:
with open(path, encoding="utf-8") as handle:
value = json.load(handle)
if isinstance(value, dict):
values.append((path, value))
except Exception:
continue
return values
def _sender_worker() -> None:
while True:
dsn, key, payload = _event_queue.get()
queued = [(dsn, key, payload)]
batch_size = max(1, int(_cfg("BATCH_SIZE", 10)))
while len(queued) < batch_size:
try:
queued.append(_event_queue.get_nowait())
except queue.Empty:
break
buffered = _buffered_events(max(0, batch_size - len(queued)))
events = [value for _path, value in buffered] + [item[2] for item in queued]
try:
outgoing = events[0] if len(events) == 1 else {"type": "batch", "events": events}
data = json.dumps(outgoing, default=str, separators=(",", ":")).encode("utf-8")
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {key}",
"X-Forwarded-Proto": "https",
}
if _cfg("COMPRESS_PAYLOADS", True) and len(data) >= int(_cfg("COMPRESSION_THRESHOLD_BYTES", 1024)):
data = gzip.compress(data, compresslevel=5)
headers["Content-Encoding"] = "gzip"
for attempt in range(max(1, int(_cfg("MAX_RETRIES", 3)))):
try:
req = urllib.request.Request(
dsn, data=data, headers=headers, method="POST",
)
with urllib.request.urlopen(req, timeout=_cfg("TIMEOUT", 5)):
_transport_stats["delivered"] += len(events)
for path, _value in buffered:
try:
os.unlink(path)
_transport_stats["replayed"] += 1
except OSError:
pass
break
except Exception:
if attempt + 1 >= max(1, int(_cfg("MAX_RETRIES", 3))):
raise
time.sleep(min(0.25 * (2 ** attempt), 1.0))
except Exception:
for _dsn, _key, failed_payload in queued:
if not _buffer_event(failed_payload):
_transport_stats["dropped"] += 1
finally:
for _item in queued:
_event_queue.task_done()
def _is_sensitive(key: str) -> bool:
configured = _cfg("SENSITIVE_KEYS", _DEFAULT_SENSITIVE_KEYS)
normalized = str(key).replace("-", "_").lower()
return normalized in {str(item).replace("-", "_").lower() for item in configured}
def _scrub(value, key="", depth=0):
if key and _is_sensitive(key):
return _FILTERED
if depth >= 8:
return "[Max depth]"
if isinstance(value, dict):
return {str(k)[:255]: _scrub(v, str(k), depth + 1) for k, v in list(value.items())[:100]}
if isinstance(value, (list, tuple)):
return [_scrub(item, depth=depth + 1) for item in value[:100]]
if value is None or isinstance(value, (bool, int, float)):
return value
return str(value)[:4000]
def _request_data(request: HttpRequest):
if not _cfg("CAPTURE_BODY", False) or request.method in {"GET", "HEAD", "OPTIONS"}:
return {}
max_length = int(_cfg("MAX_BODY_LENGTH", 4000))
try:
raw = request.body.decode("utf-8", errors="replace")
except Exception:
return {}
if len(raw) > max_length:
return {"raw": raw[:max_length], "truncated": True}
if request.content_type == "application/json":
try:
return _scrub(json.loads(raw or "{}"))
except json.JSONDecodeError:
pass
if request.POST:
return _scrub({key: values if len(values) > 1 else values[0] for key, values in request.POST.lists()})
return {"raw": raw}
def _req(request: HttpRequest) -> dict:
"""Extract useful request info while filtering credentials and cookies."""
xff = request.META.get("HTTP_X_FORWARDED_FOR", "")
ip = xff.split(",")[0].strip() if xff else request.META.get("REMOTE_ADDR", "")
if not _cfg("CAPTURE_IP_ADDRESS", False):
ip = ""
resolver_match = getattr(request, "resolver_match", None)
safe_query = ""
if _cfg("CAPTURE_QUERY_STRING", False):
safe_query = urlencode([
(key, _FILTERED if _is_sensitive(key) else value[:4000])
for key, value in parse_qsl(request.META.get("QUERY_STRING", ""), keep_blank_values=True)
], doseq=True)[:8192]
headers = {}
if _cfg("CAPTURE_HEADERS", False):
headers = {
key: (_FILTERED if _is_sensitive(key) else str(value)[:4000])
for key, value in list(request.headers.items())[:100]
}
return {
"method": request.method,
"path": request.path,
"url": request.build_absolute_uri(request.path) + (f"?{safe_query}" if safe_query else ""),
"route": getattr(resolver_match, "route", "") or "",
"view_name": getattr(resolver_match, "view_name", "") or "",
"query_string": safe_query,
"ip_address": ip,
"headers": headers,
"data": _request_data(request),
}
def _user(request: HttpRequest) -> dict:
"""Extract only the internal user ID if explicitly enabled."""
if _cfg("CAPTURE_USER_ID", True) and hasattr(request, "user") and request.user and getattr(request.user, "is_authenticated", False):
return {"id": str(request.user.pk)}
return {}
def _frames(tb):
"""Extract stack frames with enough surrounding source to debug them."""
frames = []
while tb is not None:
f = tb.tb_frame
filename = f.f_code.co_filename
line_number = tb.tb_lineno
frames.append({
"filename": filename,
"module": f.f_globals.get("__name__", ""),
"function": f.f_code.co_name,
"line_number": line_number,
"pre_context": [linecache.getline(filename, number).rstrip("\r\n") for number in range(max(1, line_number - 3), line_number)],
"context_line": linecache.getline(filename, line_number).rstrip("\r\n"),
"post_context": [linecache.getline(filename, number).rstrip("\r\n") for number in range(line_number + 1, line_number + 4)],
"in_app": "/site-packages/" not in filename and "/lib/python" not in filename,
})
tb = tb.tb_next
return frames
def _exception_values(exc, mechanism="django.middleware", handled=False):
chain, current, seen = [], exc, set()
while current is not None and id(current) not in seen and len(chain) < 10:
seen.add(id(current))
chain.append(current)
current = current.__cause__ or (None if current.__suppress_context__ else current.__context__)
return [{
"type": type(item).__name__,
"module": type(item).__module__,
"message": str(item)[:20000],
"frames": _frames(item.__traceback__),
"mechanism": {"type": mechanism, "handled": handled},
} for item in reversed(chain)]
def _base_event(level="error"):
return {
"event_id": uuid.uuid4().hex,
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": level,
"environment": _cfg("ENVIRONMENT") or getattr(settings, "DJANGO_ENV", "development"),
"release": _cfg("RELEASE") or getattr(settings, "GIT_COMMIT", ""),
"server_name": socket.gethostname(),
"sdk": {"name": "fetchnode.django-snippet", "version": "0.4.0"},
"contexts": {
"runtime": {"name": platform.python_implementation(), "version": platform.python_version()},
"django": {"version": __import__("django").get_version()},
},
}
class FetchNodeMiddleware:
"""Captures Django exceptions and application request traffic.
Add as the FIRST middleware in MIDDLEWARE for best coverage.
"""
def __init__(self, get_response):
self.get_response = get_response
def _capture_exception(self, request: HttpRequest, exc: Exception, queries=None, query_stats=None) -> None:
if not _cfg("ENABLED", False) or not _cfg("DSN") or not _cfg("KEY") or getattr(request, "_fetchnode_exception_captured", False):
return
request._fetchnode_exception_captured = True
queries = queries if queries is not None else getattr(request, "_fetchnode_queries", [])
query_stats = query_stats if query_stats is not None else getattr(
request, "_fetchnode_query_stats", {"count": len(queries), "duration_ms": sum(q["duration_ms"] for q in queries)}
)
etype = type(exc).__name__
msg = str(exc)[:20000]
values = _exception_values(exc)
event = _base_event()
event["contexts"]["database"] = {
"query_count": query_stats["count"],
"query_time_ms": round(query_stats["duration_ms"], 2),
"truncated": query_stats["count"] > len(queries),
}
event["contexts"]["trace"] = {
"trace_id": getattr(request, "_fetchnode_trace_id", ""),
"transaction_event_id": getattr(request, "_fetchnode_transaction_event_id", ""),
}
event.update({
"logger": "fetchnode.django",
"exception": {"type": etype, "message": msg, "frames": values[-1]["frames"], "values": values},
"request": _req(request),
"user": _user(request),
"queries": queries,
"trace_id": getattr(request, "_fetchnode_trace_id", ""),
"request_id": getattr(request, "_fetchnode_request_id", ""),
"tags": {"source": "server", "request_id": getattr(request, "_fetchnode_request_id", "")},
"breadcrumbs": [{
"timestamp": datetime.now(timezone.utc).isoformat(),
"category": "http.request",
"level": "info",
"message": f"{request.method} {request.path}",
}],
})
_send(event)
def process_exception(self, request: HttpRequest, exception: Exception):
"""Django calls this before converting an unhandled view exception to a 500 response."""
self._capture_exception(request, exception)
return None
def __call__(self, request: HttpRequest):
token = _active_request.set(request)
try:
return self._handle_request(request)
finally:
_active_request.reset(token)
def _handle_request(self, request: HttpRequest):
trace_id, request_id = _correlation(request)
request._fetchnode_trace_id = trace_id
request._fetchnode_request_id = request_id
request._fetchnode_transaction_event_id = uuid.uuid4().hex
queries = []
query_stats = {"count": 0, "duration_ms": 0.0, "observations": {}}
request._fetchnode_queries = queries
request._fetchnode_query_stats = query_stats
capture_transaction = _should_capture_transaction(request)
from django.db import connection
def _query_log(execute, sql, params, many, context):
start_time = time.time()
try:
return execute(sql, params, many, context)
finally:
duration = time.time() - start_time
duration_ms = round(duration * 1000, 2)
query_stats["count"] += 1
query_stats["duration_ms"] += duration_ms
if len(queries) < 50:
query = {"duration_ms": duration_ms}
if _cfg("CAPTURE_QUERY_TEXT", False):
query["sql"] = str(sql)[:20000]
queries.append(query)
_observe_query(query_stats["observations"], str(sql), duration_ms)
start_time_req = time.time()
try:
with connection.execute_wrapper(_query_log):
response = self.get_response(request)
except Exception as exc:
self._capture_exception(request, exc, queries, query_stats)
# Send transaction for failed request
duration_ms = (time.time() - start_time_req) * 1000
if _cfg("ENABLED", False) and _cfg("DSN") and _cfg("KEY") and capture_transaction:
_send(self._transaction_payload(request, 500, duration_ms, query_stats))
raise
duration_ms = (time.time() - start_time_req) * 1000
if _cfg("ENABLED", False) and _cfg("DSN") and _cfg("KEY"):
# Send application request traffic, excluding static/media assets.
if capture_transaction and (response.status_code != 404 or _cfg("CAPTURE_404", True)):
_send(self._transaction_payload(request, response.status_code, duration_ms, query_stats))
# Capture 404s
if response.status_code == 404 and _cfg("CAPTURE_404", True):
path = request.path
if not any(path.startswith(p) for p in ("/static/", "/media/", "/favicon.ico", "/robots.txt", "/sitemap")):
event = _base_event(level="warning")
event.update({
"logger": "fetchnode.django.404",
"exception": {
"type": "Http404",
"message": f"Not Found: {request.path}",
"frames": [],
"values": [{
"type": "Http404", "module": "django.http", "message": f"Not Found: {request.path}",
"frames": [], "mechanism": {"type": "django.response", "handled": True},
}],
},
"request": _req(request),
"user": _user(request),
"queries": queries,
"trace_id": trace_id,
"request_id": request_id,
"tags": {"source": "server", "type": "404", "request_id": request_id},
})
event["contexts"]["trace"] = {
"trace_id": trace_id,
"transaction_event_id": request._fetchnode_transaction_event_id,
}
_send(event)
elif response.status_code >= 500 and not getattr(request, "_fetchnode_exception_captured", False):
event = _base_event()
event.update({
"logger": "fetchnode.django.response",
"exception": {
"type": "Http500",
"message": f"Server error response: {request.method} {request.path}",
"frames": [],
"values": [{
"type": "Http500", "module": "django.http", "message": "Server returned HTTP 500",
"frames": [], "mechanism": {"type": "django.response", "handled": True},
}],
},
"request": _req(request),
"user": _user(request),
"queries": queries,
"trace_id": trace_id,
"request_id": request_id,
"tags": {"source": "server", "type": "500-response", "request_id": request_id},
})
event["contexts"]["trace"] = {
"trace_id": trace_id,
"transaction_event_id": request._fetchnode_transaction_event_id,
}
_send(event)
if _cfg("TRACE_RESPONSE_HEADER", True):
response.setdefault("X-FetchNode-Trace-ID", trace_id)
response.setdefault("X-Request-ID", request_id)
return response
def _transaction_payload(self, request, status_code, duration_ms, query_stats):
resolver_match = getattr(request, "resolver_match", None)
user = _user(request)
return {
"event_id": request._fetchnode_transaction_event_id,
"type": "transaction",
"timestamp": datetime.now(timezone.utc).isoformat(),
"method": request.method,
"path": request.path,
"route": getattr(resolver_match, "route", "") or "",
"view_name": getattr(resolver_match, "view_name", "") or "",
"status_code": status_code,
"duration_ms": round(duration_ms, 2),
"db_queries_count": query_stats["count"],
"db_time_ms": round(query_stats["duration_ms"], 2),
"query_observations": sorted(
query_stats["observations"].values(),
key=lambda item: (not item["suspected_n_plus_one"], -item["total_duration_ms"]),
),
"environment": _cfg("ENVIRONMENT") or getattr(settings, "DJANGO_ENV", "development"),
"release": _cfg("RELEASE") or getattr(settings, "GIT_COMMIT", ""),
"trace_id": request._fetchnode_trace_id,
"request_id": request._fetchnode_request_id,
"user_identifier": user.get("id", ""),
"visitor_id": _safe_header_id(request, "X-FetchNode-Visitor-ID"),
"session_id": _safe_header_id(request, "X-FetchNode-Session-ID"),
}
class FetchNodeHandler(logging.Handler):
"""Logging handler that sends ERROR+ records to FetchNode.
Add to your LOGGING settings:
LOGGING = {
'handlers': {
'fetchnode': {
'level': 'ERROR',
'class': f'{__name__}.FetchNodeHandler',
},
},
'loggers': {
'django': {'handlers': ['fetchnode'], 'level': 'ERROR'},
},
}
"""
def emit(self, record: logging.LogRecord) -> None:
try:
if not _cfg("ENABLED", False) or not _cfg("DSN") or not _cfg("KEY"):
return
if record.levelno < logging.ERROR or getattr(record, "_fetchnode_logging_captured", False):
return
record._fetchnode_logging_captured = True
exc_info = record.exc_info
etype = exc_info[0].__name__ if exc_info and exc_info[0] else "Error"
msg = str(exc_info[1])[:2000] if exc_info and exc_info[1] else record.getMessage()
values = _exception_values(exc_info[1], mechanism="logging", handled=True) if exc_info and exc_info[1] else []
request = _active_request.get()
event = _base_event(level=record.levelname.lower())
event["timestamp"] = datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat()
event.update({
"logger": record.name or "fetchnode.django.logging",
"exception": {
"type": etype,
"message": msg,
"frames": values[-1]["frames"] if values else [],
"values": values,
},
"request": _req(request) if request is not None else {},
"user": _user(request) if request is not None else {},
"tags": {"source": "server", "type": "log"},
})
if request is not None:
trace_id = getattr(request, "_fetchnode_trace_id", "")
request_id = getattr(request, "_fetchnode_request_id", "")
event["trace_id"] = trace_id
event["request_id"] = request_id
event["tags"]["request_id"] = request_id
event["contexts"]["trace"] = {
"trace_id": trace_id,
"transaction_event_id": getattr(request, "_fetchnode_transaction_event_id", ""),
}
event["breadcrumbs"] = [{
"timestamp": datetime.now(timezone.utc).isoformat(),
"category": "http.request",
"level": "info",
"message": f"{request.method} {request.path}",
}]
_send(event)
except Exception:
self.handleError(record)
def fetchnode_track(
event_name: str,
payload: dict = None,
user_identifier: str = "",
visitor_id: str = "",
session_id: str = "",
path: str = "",
):
"""Send a custom event to FetchNode. Usage: settings.fetchnode_track('signup', {'plan': 'pro'})"""
if not _cfg("ENABLED", False) or not _cfg("DSN") or not _cfg("KEY"):
return
_send({
"event_id": uuid.uuid4().hex,
"type": "custom",
"name": event_name,
"payload": payload or {},
"user_identifier": user_identifier,
"visitor_id": visitor_id,
"session_id": session_id,
"path": path,
"timestamp": datetime.now(timezone.utc).isoformat(),
"environment": _cfg("ENVIRONMENT") or getattr(settings, "DJANGO_ENV", "development"),
})
Set FETCHNODE_ENABLED=1 only in production and provide FETCHNODE_ENVIRONMENT and FETCHNODE_RELEASE during deployment. The Django block exposes those values to the browser snippet; tests are always excluded. Safe defaults exclude static/media requests, scanner 404 errors, headers, bodies, IP addresses, query strings, SQL parameters and raw SQL text; normalized query structure supports duplicate and N+1 detection. Only the authenticated user's internal ID is included.
Step 3
Deploy or restart Django
settings.py is imported again. Then open a normal public page once. Saving the files without a deploy or restart does not activate FetchNode.
Step 4
Verify it works
Run these checks on the deployed website, not only on localhost. Accept analytics cookies first (or call FetchNode("consent", {analytics: true})) before expecting a pageview. Keep the FetchNode dashboard filtered to the selected website.
Browser error
Open your website console and run:
FetchNode("captureException", new Error("FetchNode test — browser"));
Server error
Temporarily add this entry inside urlpatterns in urls.py, deploy, visit it once, then remove it and deploy again:
path("__fetchnode-test__/", lambda request: (_ for _ in ()).throw(RuntimeError("FetchNode test — server"))),
Expected within about 10 seconds
- After analytics consent, a pageview for the deployed website URL.
- A Django request transaction with status 200.
- A browser
Errorissue fromfetchnode.browser. - A server
RuntimeErrorand a status-500 transaction.
If the status stays Partial
- Errors arrive, but traffic and pageviews are missing
- An older errors-only integration is still deployed. Replace both snippets from this selected project and redeploy.
- Django traffic arrives, but pageviews are missing
- The browser snippet is absent from the rendered base template or its widget is blocked. View page source, confirm this project key and open the widget URL; it must return JavaScript with HTTP 200.
- Pageviews arrive, but Django traffic is missing
- The settings snippet is not active in the production settings module. Paste the complete block at the bottom and restart every Django process.
- Signals appear under another website
- The wrong project was selected when copying. Select this website above, replace both snippets and redeploy.
Assistant prompt
If you prefer to let your coding assistant make the change, copy this prompt into the project you want to connect.
Prompt
Use this in the codebase you want to connect.
Set up FetchNode in this Django project: error tracking plus lightweight analytics.
Goals:
1. Capture browser JavaScript errors, pageviews, user identity and key product events with the FetchNode browser snippet.
2. Capture Django server exceptions, application request duration, and database query counts by pasting the FetchNode settings.py snippet.
3. Verify one browser error, one server error, and pageviews appear in the FetchNode dashboard.
Use this project key:
PROJECT_KEY_HIDDEN
Step 1 — add the browser snippet before </head> in the base template used by all pages:
```html
<script>
window.FetchNodeQueue = window.FetchNodeQueue || [];
window.FetchNode = window.FetchNode || function () {
window.FetchNodeQueue.push(arguments);
};
FetchNode("init", {
projectKey: "PROJECT_KEY_HIDDEN",
endpoint: "https://fetch-node.com/api/events/ingest/",
enabled: {{ fetchnode_browser.enabled|yesno:"true,false" }},
environment: "{{ fetchnode_browser.environment|escapejs }}",
release: "{{ fetchnode_browser.release|escapejs }}",
trackPageviews: true
});
if (typeof window.FETCHNODE_ANALYTICS_CONSENT === 'boolean') {
FetchNode("consent", {analytics: window.FETCHNODE_ANALYTICS_CONSENT});
}
</script>
<script async src="https://fetch-node.com/widget/v1/widget.min.js?v=20260802-2" crossorigin="anonymous"></script>
```
Step 2 — paste this self-contained Django snippet at the very bottom of settings.py:
```python
# FetchNode — paste everything in this block at the bottom of settings.py.
# No pip install, no downloaded file, no dependency: only the Python standard library + Django.
import os
import sys
def _fetchnode_env_bool(name, default=False):
return os.getenv(name, '1' if default else '0').lower() in {'1', 'true', 'yes', 'on'}
FETCHNODE = {
'ENABLED': _fetchnode_env_bool('FETCHNODE_ENABLED', False) and not any(arg == 'test' or 'pytest' in arg for arg in sys.argv),
'DSN': 'https://fetch-node.com/api/events/ingest/',
'KEY': 'PROJECT_KEY_HIDDEN',
'ENVIRONMENT': os.getenv('FETCHNODE_ENVIRONMENT', 'development'),
'RELEASE': os.getenv('FETCHNODE_RELEASE', ''), # Deployment version or git SHA.
'TIMEOUT': 5,
'MAX_RETRIES': 3,
'BATCH_SIZE': 10,
'COMPRESS_PAYLOADS': True,
'COMPRESSION_THRESHOLD_BYTES': 1024,
'DISK_BUFFER_PATH': os.getenv('FETCHNODE_BUFFER_PATH', ''), # Optional durable retry spool.
'MAX_BUFFERED_EVENTS': 1000,
'TRANSACTION_SAMPLE_RATE': 1.0, # Errors, 5xx responses and latency outliers remain at 100%.
'KEEP_TRANSACTION_OUTLIER_MS': 1000,
'CAPTURE_TRANSACTIONS': True,
'TRANSACTION_EXCLUDE_PREFIXES': ('/widget/', '/static/', '/media/', '/favicon.ico', '/robots.txt', '/llms.txt', '/sitemap', '/health/', '/ready/'),
'CAPTURE_404': False, # Avoid public scanner noise; set True only when needed.
'CAPTURE_HEADERS': False,
'CAPTURE_BODY': False, # Safer privacy default; opt in deliberately.
'CAPTURE_IP_ADDRESS': False,
'CAPTURE_QUERY_STRING': False,
'CAPTURE_QUERY_TEXT': False,
'CAPTURE_QUERY_SHAPE': True, # Normalized SQL structure only; never query parameters.
'QUERY_SLOW_THRESHOLD_MS': 100,
'N_PLUS_ONE_THRESHOLD': 5,
'MAX_QUERY_OBSERVATIONS': 50,
'TRACE_RESPONSE_HEADER': True,
'CAPTURE_USER_ID': True, # Never sends username or email.
'MAX_BODY_LENGTH': 4000,
}
def fetchnode_template_context(request):
return {'fetchnode_browser': {
'enabled': FETCHNODE['ENABLED'],
'environment': FETCHNODE['ENVIRONMENT'],
'release': FETCHNODE['RELEASE'],
}}
_fetchnode_context_processor = f'{__name__}.fetchnode_template_context'
for _fetchnode_template in TEMPLATES:
_fetchnode_options = _fetchnode_template.setdefault('OPTIONS', {})
_fetchnode_processors = list(_fetchnode_options.get('context_processors', []))
if _fetchnode_context_processor not in _fetchnode_processors:
_fetchnode_processors.append(_fetchnode_context_processor)
_fetchnode_options['context_processors'] = _fetchnode_processors
# FetchNodeMiddleware is defined below in this same settings.py snippet.
# Keep it first so it sees every request, exception, 404, and slow query.
if FETCHNODE['ENABLED'] and f'{__name__}.FetchNodeMiddleware' not in MIDDLEWARE:
MIDDLEWARE = [f'{__name__}.FetchNodeMiddleware', *MIDDLEWARE]
# Capture handled application errors (logger.error/logger.exception), not only
# exceptions that escape a Django view. Keep existing logging configuration intact.
LOGGING = globals().get('LOGGING', {})
LOGGING.setdefault('version', 1)
LOGGING.setdefault('disable_existing_loggers', False)
_fetchnode_handlers = LOGGING.setdefault('handlers', {})
_fetchnode_handlers.setdefault('fetchnode', {
'level': 'ERROR',
'class': f'{__name__}.FetchNodeHandler',
})
_fetchnode_root = LOGGING.setdefault('root', {})
_fetchnode_root_handlers = list(_fetchnode_root.get('handlers', []))
if 'fetchnode' not in _fetchnode_root_handlers:
_fetchnode_root_handlers.append('fetchnode')
_fetchnode_root['handlers'] = _fetchnode_root_handlers
_fetchnode_root.setdefault('level', 'WARNING')
for _fetchnode_logger in LOGGING.setdefault('loggers', {}).values():
if not _fetchnode_logger.get('propagate', True):
_fetchnode_logger_handlers = list(_fetchnode_logger.get('handlers', []))
if 'fetchnode' not in _fetchnode_logger_handlers:
_fetchnode_logger_handlers.append('fetchnode')
_fetchnode_logger['handlers'] = _fetchnode_logger_handlers
"""
FetchNode Django Snippet
========================================
Paste this snippet at the very bottom of your Django settings.py file.
It captures unhandled exceptions, ERROR+ application logs, and request traffic.
Set CAPTURE_404 to True only when you intentionally want missing routes as
error events.
"""
import json
import gzip
import hashlib
import linecache
import logging
import platform
import os
import queue
import re
import socket
import threading
import time
import traceback
import uuid
import urllib.request
from contextvars import ContextVar
from datetime import datetime, timezone
from urllib.parse import parse_qsl, urlencode
from django.conf import settings
from django.http import HttpRequest
logger = logging.getLogger("fetchnode.middleware")
_event_queue = queue.Queue(maxsize=500)
_sender_lock = threading.Lock()
_sender_started = False
_transport_stats = {"queued": 0, "delivered": 0, "buffered": 0, "dropped": 0, "replayed": 0}
_active_request = ContextVar("fetchnode_active_request", default=None)
_FILTERED = "[Filtered]"
_DEFAULT_SENSITIVE_KEYS = {
"password", "passwd", "secret", "token", "api_key", "access_token",
"refresh_token", "authorization", "cookie", "csrfmiddlewaretoken", "x_api_key",
}
_DEFAULT_TRANSACTION_EXCLUDE_PREFIXES = (
"/widget/", "/static/", "/media/", "/favicon.ico", "/robots.txt", "/sitemap", "/health/", "/ready/",
)
_TRACEPARENT = re.compile(r"^[\da-f]{2}-([\da-f]{32})-[\da-f]{16}-[\da-f]{2}$", re.IGNORECASE)
_SAFE_ID = re.compile(r"^[\w.-]{1,64}$")
_STRING_LITERAL = re.compile(r"'(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"")
_NUMBER_LITERAL = re.compile(r"(?<![\w.])-?\d+(?:\.\d+)?(?![\w.])")
_TABLE = re.compile(r"\b(?:from|join|update|into)\s+(?:only\s+)?[\"`\[]?([\w.]+)", re.IGNORECASE)
def _cfg(key: str, default=""):
"""Read a value from settings.FETCHNODE with a default."""
return getattr(settings, "FETCHNODE", {}).get(key, default)
def _correlation(request: HttpRequest) -> tuple[str, str]:
match = _TRACEPARENT.fullmatch(request.headers.get("Traceparent", "").strip())
supplied_trace = request.headers.get("X-FetchNode-Trace-ID", "").strip().lower()
trace_id = match.group(1).lower() if match else supplied_trace
if not re.fullmatch(r"[\da-f]{32}", trace_id):
trace_id = uuid.uuid4().hex
supplied_request = request.headers.get("X-Request-ID", "").strip()
request_id = supplied_request if _SAFE_ID.fullmatch(supplied_request) else uuid.uuid4().hex
return trace_id, request_id
def _safe_header_id(request: HttpRequest, name: str) -> str:
value = request.headers.get(name, "").strip()
return value if _SAFE_ID.fullmatch(value) else ""
def _query_identity(sql: str) -> tuple[str, str, str, str]:
normalized = _STRING_LITERAL.sub("?", str(sql or ""))
normalized = _NUMBER_LITERAL.sub("?", normalized)
normalized = re.sub(r"(?:%s|\?|:\w+)(?:\s*,\s*(?:%s|\?|:\w+))+", "?", normalized, flags=re.IGNORECASE)
normalized = re.sub(r"\s+", " ", normalized).strip()[:20000]
operation_match = re.match(r"\s*([a-z]+)", normalized, re.IGNORECASE)
operation = operation_match.group(1).upper()[:16] if operation_match else "QUERY"
table_match = _TABLE.search(normalized)
table = table_match.group(1)[:255] if table_match else ""
return hashlib.sha256(normalized.lower().encode()).hexdigest(), operation, table, normalized
def _query_location() -> str:
for frame in reversed(traceback.extract_stack(limit=30)[:-1]):
filename = frame.filename.replace("\\", "/")
if any(marker in filename for marker in ("/django/", "/site-packages/", "django_middleware.py")):
continue
return f"{frame.filename}:{frame.lineno} in {frame.name}"[:1024]
return ""
def _observe_query(observations: dict, sql: str, duration_ms: float) -> None:
fingerprint, operation, table, normalized = _query_identity(sql)
location = _query_location()
key = (fingerprint, location)
item = observations.get(key)
if item is None:
if len(observations) >= int(_cfg("MAX_QUERY_OBSERVATIONS", 50)):
return
item = {
"fingerprint": fingerprint, "operation": operation, "table": table,
"normalized_sql": normalized if _cfg("CAPTURE_QUERY_SHAPE", True) else "",
"stack_location": location, "count": 0, "total_duration_ms": 0.0, "max_duration_ms": 0.0,
}
observations[key] = item
item["count"] += 1
item["total_duration_ms"] = round(item["total_duration_ms"] + duration_ms, 2)
item["max_duration_ms"] = round(max(item["max_duration_ms"], duration_ms), 2)
item["is_slow"] = item["max_duration_ms"] >= float(_cfg("QUERY_SLOW_THRESHOLD_MS", 100))
item["is_duplicate"] = item["count"] > 1
item["suspected_n_plus_one"] = item["count"] >= int(_cfg("N_PLUS_ONE_THRESHOLD", 5)) and operation == "SELECT"
def _should_capture_transaction(request: HttpRequest) -> bool:
if not _cfg("ENABLED", False) or not _cfg("CAPTURE_TRANSACTIONS", True):
return False
prefixes = _cfg("TRANSACTION_EXCLUDE_PREFIXES", _DEFAULT_TRANSACTION_EXCLUDE_PREFIXES)
if isinstance(prefixes, str):
prefixes = [prefix.strip() for prefix in prefixes.split(",") if prefix.strip()]
return not any(request.path.startswith(str(prefix)) for prefix in prefixes)
def _send(payload: dict) -> None:
"""Queue telemetry without creating an unbounded thread per request."""
dsn, key = _cfg("DSN"), _cfg("KEY")
if not _cfg("ENABLED", False) or not dsn or not key:
return
if payload.get("type") == "transaction" and int(payload.get("status_code") or 0) < 500:
duration = float(payload.get("duration_ms") or 0)
rate = min(1.0, max(0.0, float(_cfg("TRANSACTION_SAMPLE_RATE", 1.0))))
if duration < float(_cfg("KEEP_TRANSACTION_OUTLIER_MS", 1000)) and rate < 1:
stable_id = str(payload.get("event_id") or uuid.uuid4().hex)
bucket = int(hashlib.sha256(stable_id.encode()).hexdigest()[:8], 16) / 0xFFFFFFFF
if bucket >= rate:
return
global _sender_started
with _sender_lock:
if not _sender_started:
threading.Thread(target=_sender_worker, daemon=True, name="fetchnode-sender").start()
_sender_started = True
try:
_event_queue.put_nowait((dsn, key, payload))
_transport_stats["queued"] += 1
except queue.Full:
if not _buffer_event(payload):
_transport_stats["dropped"] += 1
def fetchnode_transport_stats() -> dict:
"""Return local sender health for readiness checks and diagnostics."""
return {**_transport_stats, "queue_size": _event_queue.qsize(), "unfinished": _event_queue.unfinished_tasks}
def _buffer_event(payload: dict) -> bool:
directory = str(_cfg("DISK_BUFFER_PATH", "") or "").strip()
if not directory:
return False
try:
os.makedirs(directory, mode=0o700, exist_ok=True)
files = sorted(name for name in os.listdir(directory) if name.endswith(".json"))
if len(files) >= int(_cfg("MAX_BUFFERED_EVENTS", 1000)):
return False
path = os.path.join(directory, f"{time.time_ns()}-{uuid.uuid4().hex}.json")
temporary = path + ".tmp"
with open(temporary, "x", encoding="utf-8") as handle:
json.dump(payload, handle, default=str, separators=(",", ":"))
os.chmod(temporary, 0o600)
os.replace(temporary, path)
_transport_stats["buffered"] += 1
return True
except Exception:
return False
def _buffered_events(limit: int):
directory = str(_cfg("DISK_BUFFER_PATH", "") or "").strip()
if not directory or not os.path.isdir(directory):
return []
values = []
for name in sorted(item for item in os.listdir(directory) if item.endswith(".json"))[:limit]:
path = os.path.join(directory, name)
try:
with open(path, encoding="utf-8") as handle:
value = json.load(handle)
if isinstance(value, dict):
values.append((path, value))
except Exception:
continue
return values
def _sender_worker() -> None:
while True:
dsn, key, payload = _event_queue.get()
queued = [(dsn, key, payload)]
batch_size = max(1, int(_cfg("BATCH_SIZE", 10)))
while len(queued) < batch_size:
try:
queued.append(_event_queue.get_nowait())
except queue.Empty:
break
buffered = _buffered_events(max(0, batch_size - len(queued)))
events = [value for _path, value in buffered] + [item[2] for item in queued]
try:
outgoing = events[0] if len(events) == 1 else {"type": "batch", "events": events}
data = json.dumps(outgoing, default=str, separators=(",", ":")).encode("utf-8")
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {key}",
"X-Forwarded-Proto": "https",
}
if _cfg("COMPRESS_PAYLOADS", True) and len(data) >= int(_cfg("COMPRESSION_THRESHOLD_BYTES", 1024)):
data = gzip.compress(data, compresslevel=5)
headers["Content-Encoding"] = "gzip"
for attempt in range(max(1, int(_cfg("MAX_RETRIES", 3)))):
try:
req = urllib.request.Request(
dsn, data=data, headers=headers, method="POST",
)
with urllib.request.urlopen(req, timeout=_cfg("TIMEOUT", 5)):
_transport_stats["delivered"] += len(events)
for path, _value in buffered:
try:
os.unlink(path)
_transport_stats["replayed"] += 1
except OSError:
pass
break
except Exception:
if attempt + 1 >= max(1, int(_cfg("MAX_RETRIES", 3))):
raise
time.sleep(min(0.25 * (2 ** attempt), 1.0))
except Exception:
for _dsn, _key, failed_payload in queued:
if not _buffer_event(failed_payload):
_transport_stats["dropped"] += 1
finally:
for _item in queued:
_event_queue.task_done()
def _is_sensitive(key: str) -> bool:
configured = _cfg("SENSITIVE_KEYS", _DEFAULT_SENSITIVE_KEYS)
normalized = str(key).replace("-", "_").lower()
return normalized in {str(item).replace("-", "_").lower() for item in configured}
def _scrub(value, key="", depth=0):
if key and _is_sensitive(key):
return _FILTERED
if depth >= 8:
return "[Max depth]"
if isinstance(value, dict):
return {str(k)[:255]: _scrub(v, str(k), depth + 1) for k, v in list(value.items())[:100]}
if isinstance(value, (list, tuple)):
return [_scrub(item, depth=depth + 1) for item in value[:100]]
if value is None or isinstance(value, (bool, int, float)):
return value
return str(value)[:4000]
def _request_data(request: HttpRequest):
if not _cfg("CAPTURE_BODY", False) or request.method in {"GET", "HEAD", "OPTIONS"}:
return {}
max_length = int(_cfg("MAX_BODY_LENGTH", 4000))
try:
raw = request.body.decode("utf-8", errors="replace")
except Exception:
return {}
if len(raw) > max_length:
return {"raw": raw[:max_length], "truncated": True}
if request.content_type == "application/json":
try:
return _scrub(json.loads(raw or "{}"))
except json.JSONDecodeError:
pass
if request.POST:
return _scrub({key: values if len(values) > 1 else values[0] for key, values in request.POST.lists()})
return {"raw": raw}
def _req(request: HttpRequest) -> dict:
"""Extract useful request info while filtering credentials and cookies."""
xff = request.META.get("HTTP_X_FORWARDED_FOR", "")
ip = xff.split(",")[0].strip() if xff else request.META.get("REMOTE_ADDR", "")
if not _cfg("CAPTURE_IP_ADDRESS", False):
ip = ""
resolver_match = getattr(request, "resolver_match", None)
safe_query = ""
if _cfg("CAPTURE_QUERY_STRING", False):
safe_query = urlencode([
(key, _FILTERED if _is_sensitive(key) else value[:4000])
for key, value in parse_qsl(request.META.get("QUERY_STRING", ""), keep_blank_values=True)
], doseq=True)[:8192]
headers = {}
if _cfg("CAPTURE_HEADERS", False):
headers = {
key: (_FILTERED if _is_sensitive(key) else str(value)[:4000])
for key, value in list(request.headers.items())[:100]
}
return {
"method": request.method,
"path": request.path,
"url": request.build_absolute_uri(request.path) + (f"?{safe_query}" if safe_query else ""),
"route": getattr(resolver_match, "route", "") or "",
"view_name": getattr(resolver_match, "view_name", "") or "",
"query_string": safe_query,
"ip_address": ip,
"headers": headers,
"data": _request_data(request),
}
def _user(request: HttpRequest) -> dict:
"""Extract only the internal user ID if explicitly enabled."""
if _cfg("CAPTURE_USER_ID", True) and hasattr(request, "user") and request.user and getattr(request.user, "is_authenticated", False):
return {"id": str(request.user.pk)}
return {}
def _frames(tb):
"""Extract stack frames with enough surrounding source to debug them."""
frames = []
while tb is not None:
f = tb.tb_frame
filename = f.f_code.co_filename
line_number = tb.tb_lineno
frames.append({
"filename": filename,
"module": f.f_globals.get("__name__", ""),
"function": f.f_code.co_name,
"line_number": line_number,
"pre_context": [linecache.getline(filename, number).rstrip("\r\n") for number in range(max(1, line_number - 3), line_number)],
"context_line": linecache.getline(filename, line_number).rstrip("\r\n"),
"post_context": [linecache.getline(filename, number).rstrip("\r\n") for number in range(line_number + 1, line_number + 4)],
"in_app": "/site-packages/" not in filename and "/lib/python" not in filename,
})
tb = tb.tb_next
return frames
def _exception_values(exc, mechanism="django.middleware", handled=False):
chain, current, seen = [], exc, set()
while current is not None and id(current) not in seen and len(chain) < 10:
seen.add(id(current))
chain.append(current)
current = current.__cause__ or (None if current.__suppress_context__ else current.__context__)
return [{
"type": type(item).__name__,
"module": type(item).__module__,
"message": str(item)[:20000],
"frames": _frames(item.__traceback__),
"mechanism": {"type": mechanism, "handled": handled},
} for item in reversed(chain)]
def _base_event(level="error"):
return {
"event_id": uuid.uuid4().hex,
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": level,
"environment": _cfg("ENVIRONMENT") or getattr(settings, "DJANGO_ENV", "development"),
"release": _cfg("RELEASE") or getattr(settings, "GIT_COMMIT", ""),
"server_name": socket.gethostname(),
"sdk": {"name": "fetchnode.django-snippet", "version": "0.4.0"},
"contexts": {
"runtime": {"name": platform.python_implementation(), "version": platform.python_version()},
"django": {"version": __import__("django").get_version()},
},
}
class FetchNodeMiddleware:
"""Captures Django exceptions and application request traffic.
Add as the FIRST middleware in MIDDLEWARE for best coverage.
"""
def __init__(self, get_response):
self.get_response = get_response
def _capture_exception(self, request: HttpRequest, exc: Exception, queries=None, query_stats=None) -> None:
if not _cfg("ENABLED", False) or not _cfg("DSN") or not _cfg("KEY") or getattr(request, "_fetchnode_exception_captured", False):
return
request._fetchnode_exception_captured = True
queries = queries if queries is not None else getattr(request, "_fetchnode_queries", [])
query_stats = query_stats if query_stats is not None else getattr(
request, "_fetchnode_query_stats", {"count": len(queries), "duration_ms": sum(q["duration_ms"] for q in queries)}
)
etype = type(exc).__name__
msg = str(exc)[:20000]
values = _exception_values(exc)
event = _base_event()
event["contexts"]["database"] = {
"query_count": query_stats["count"],
"query_time_ms": round(query_stats["duration_ms"], 2),
"truncated": query_stats["count"] > len(queries),
}
event["contexts"]["trace"] = {
"trace_id": getattr(request, "_fetchnode_trace_id", ""),
"transaction_event_id": getattr(request, "_fetchnode_transaction_event_id", ""),
}
event.update({
"logger": "fetchnode.django",
"exception": {"type": etype, "message": msg, "frames": values[-1]["frames"], "values": values},
"request": _req(request),
"user": _user(request),
"queries": queries,
"trace_id": getattr(request, "_fetchnode_trace_id", ""),
"request_id": getattr(request, "_fetchnode_request_id", ""),
"tags": {"source": "server", "request_id": getattr(request, "_fetchnode_request_id", "")},
"breadcrumbs": [{
"timestamp": datetime.now(timezone.utc).isoformat(),
"category": "http.request",
"level": "info",
"message": f"{request.method} {request.path}",
}],
})
_send(event)
def process_exception(self, request: HttpRequest, exception: Exception):
"""Django calls this before converting an unhandled view exception to a 500 response."""
self._capture_exception(request, exception)
return None
def __call__(self, request: HttpRequest):
token = _active_request.set(request)
try:
return self._handle_request(request)
finally:
_active_request.reset(token)
def _handle_request(self, request: HttpRequest):
trace_id, request_id = _correlation(request)
request._fetchnode_trace_id = trace_id
request._fetchnode_request_id = request_id
request._fetchnode_transaction_event_id = uuid.uuid4().hex
queries = []
query_stats = {"count": 0, "duration_ms": 0.0, "observations": {}}
request._fetchnode_queries = queries
request._fetchnode_query_stats = query_stats
capture_transaction = _should_capture_transaction(request)
from django.db import connection
def _query_log(execute, sql, params, many, context):
start_time = time.time()
try:
return execute(sql, params, many, context)
finally:
duration = time.time() - start_time
duration_ms = round(duration * 1000, 2)
query_stats["count"] += 1
query_stats["duration_ms"] += duration_ms
if len(queries) < 50:
query = {"duration_ms": duration_ms}
if _cfg("CAPTURE_QUERY_TEXT", False):
query["sql"] = str(sql)[:20000]
queries.append(query)
_observe_query(query_stats["observations"], str(sql), duration_ms)
start_time_req = time.time()
try:
with connection.execute_wrapper(_query_log):
response = self.get_response(request)
except Exception as exc:
self._capture_exception(request, exc, queries, query_stats)
# Send transaction for failed request
duration_ms = (time.time() - start_time_req) * 1000
if _cfg("ENABLED", False) and _cfg("DSN") and _cfg("KEY") and capture_transaction:
_send(self._transaction_payload(request, 500, duration_ms, query_stats))
raise
duration_ms = (time.time() - start_time_req) * 1000
if _cfg("ENABLED", False) and _cfg("DSN") and _cfg("KEY"):
# Send application request traffic, excluding static/media assets.
if capture_transaction and (response.status_code != 404 or _cfg("CAPTURE_404", True)):
_send(self._transaction_payload(request, response.status_code, duration_ms, query_stats))
# Capture 404s
if response.status_code == 404 and _cfg("CAPTURE_404", True):
path = request.path
if not any(path.startswith(p) for p in ("/static/", "/media/", "/favicon.ico", "/robots.txt", "/sitemap")):
event = _base_event(level="warning")
event.update({
"logger": "fetchnode.django.404",
"exception": {
"type": "Http404",
"message": f"Not Found: {request.path}",
"frames": [],
"values": [{
"type": "Http404", "module": "django.http", "message": f"Not Found: {request.path}",
"frames": [], "mechanism": {"type": "django.response", "handled": True},
}],
},
"request": _req(request),
"user": _user(request),
"queries": queries,
"trace_id": trace_id,
"request_id": request_id,
"tags": {"source": "server", "type": "404", "request_id": request_id},
})
event["contexts"]["trace"] = {
"trace_id": trace_id,
"transaction_event_id": request._fetchnode_transaction_event_id,
}
_send(event)
elif response.status_code >= 500 and not getattr(request, "_fetchnode_exception_captured", False):
event = _base_event()
event.update({
"logger": "fetchnode.django.response",
"exception": {
"type": "Http500",
"message": f"Server error response: {request.method} {request.path}",
"frames": [],
"values": [{
"type": "Http500", "module": "django.http", "message": "Server returned HTTP 500",
"frames": [], "mechanism": {"type": "django.response", "handled": True},
}],
},
"request": _req(request),
"user": _user(request),
"queries": queries,
"trace_id": trace_id,
"request_id": request_id,
"tags": {"source": "server", "type": "500-response", "request_id": request_id},
})
event["contexts"]["trace"] = {
"trace_id": trace_id,
"transaction_event_id": request._fetchnode_transaction_event_id,
}
_send(event)
if _cfg("TRACE_RESPONSE_HEADER", True):
response.setdefault("X-FetchNode-Trace-ID", trace_id)
response.setdefault("X-Request-ID", request_id)
return response
def _transaction_payload(self, request, status_code, duration_ms, query_stats):
resolver_match = getattr(request, "resolver_match", None)
user = _user(request)
return {
"event_id": request._fetchnode_transaction_event_id,
"type": "transaction",
"timestamp": datetime.now(timezone.utc).isoformat(),
"method": request.method,
"path": request.path,
"route": getattr(resolver_match, "route", "") or "",
"view_name": getattr(resolver_match, "view_name", "") or "",
"status_code": status_code,
"duration_ms": round(duration_ms, 2),
"db_queries_count": query_stats["count"],
"db_time_ms": round(query_stats["duration_ms"], 2),
"query_observations": sorted(
query_stats["observations"].values(),
key=lambda item: (not item["suspected_n_plus_one"], -item["total_duration_ms"]),
),
"environment": _cfg("ENVIRONMENT") or getattr(settings, "DJANGO_ENV", "development"),
"release": _cfg("RELEASE") or getattr(settings, "GIT_COMMIT", ""),
"trace_id": request._fetchnode_trace_id,
"request_id": request._fetchnode_request_id,
"user_identifier": user.get("id", ""),
"visitor_id": _safe_header_id(request, "X-FetchNode-Visitor-ID"),
"session_id": _safe_header_id(request, "X-FetchNode-Session-ID"),
}
class FetchNodeHandler(logging.Handler):
"""Logging handler that sends ERROR+ records to FetchNode.
Add to your LOGGING settings:
LOGGING = {
'handlers': {
'fetchnode': {
'level': 'ERROR',
'class': f'{__name__}.FetchNodeHandler',
},
},
'loggers': {
'django': {'handlers': ['fetchnode'], 'level': 'ERROR'},
},
}
"""
def emit(self, record: logging.LogRecord) -> None:
try:
if not _cfg("ENABLED", False) or not _cfg("DSN") or not _cfg("KEY"):
return
if record.levelno < logging.ERROR or getattr(record, "_fetchnode_logging_captured", False):
return
record._fetchnode_logging_captured = True
exc_info = record.exc_info
etype = exc_info[0].__name__ if exc_info and exc_info[0] else "Error"
msg = str(exc_info[1])[:2000] if exc_info and exc_info[1] else record.getMessage()
values = _exception_values(exc_info[1], mechanism="logging", handled=True) if exc_info and exc_info[1] else []
request = _active_request.get()
event = _base_event(level=record.levelname.lower())
event["timestamp"] = datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat()
event.update({
"logger": record.name or "fetchnode.django.logging",
"exception": {
"type": etype,
"message": msg,
"frames": values[-1]["frames"] if values else [],
"values": values,
},
"request": _req(request) if request is not None else {},
"user": _user(request) if request is not None else {},
"tags": {"source": "server", "type": "log"},
})
if request is not None:
trace_id = getattr(request, "_fetchnode_trace_id", "")
request_id = getattr(request, "_fetchnode_request_id", "")
event["trace_id"] = trace_id
event["request_id"] = request_id
event["tags"]["request_id"] = request_id
event["contexts"]["trace"] = {
"trace_id": trace_id,
"transaction_event_id": getattr(request, "_fetchnode_transaction_event_id", ""),
}
event["breadcrumbs"] = [{
"timestamp": datetime.now(timezone.utc).isoformat(),
"category": "http.request",
"level": "info",
"message": f"{request.method} {request.path}",
}]
_send(event)
except Exception:
self.handleError(record)
def fetchnode_track(
event_name: str,
payload: dict = None,
user_identifier: str = "",
visitor_id: str = "",
session_id: str = "",
path: str = "",
):
"""Send a custom event to FetchNode. Usage: settings.fetchnode_track('signup', {'plan': 'pro'})"""
if not _cfg("ENABLED", False) or not _cfg("DSN") or not _cfg("KEY"):
return
_send({
"event_id": uuid.uuid4().hex,
"type": "custom",
"name": event_name,
"payload": payload or {},
"user_identifier": user_identifier,
"visitor_id": visitor_id,
"session_id": session_id,
"path": path,
"timestamp": datetime.now(timezone.utc).isoformat(),
"environment": _cfg("ENVIRONMENT") or getattr(settings, "DJANGO_ENV", "development"),
})
```
Step 3 — verify:
- Accept analytics cookies on the deployed site, or run FetchNode("consent", {analytics: true}) before checking analytics.
- In the browser console, run: FetchNode("captureException", new Error("FetchNode test — browser"));
- Refresh a normal page to create both a browser pageview and Django request transaction.
- After login call FetchNode("identify", {id: String(userId)}).
- Track a real milestone with FetchNode("track", "signup_completed", {plan: "free"}).
- Add a temporary Django URL that raises RuntimeError("FetchNode test — server"), deploy, visit it once, then remove it and deploy again.
- Configure signup_completed as a key event and check users, sessions, sources and conversion in the dashboard.
Important notes:
- Do not install any package and do not download a Python file; use the pasted snippets only.
- The snippets already contain the selected project key from Project Settings → Installation.
- The Django snippet defines FetchNodeMiddleware and registers it automatically in MIDDLEWARE.
- Restart or redeploy Django after changing settings.py; editing the file alone does not activate middleware.
- Static/media requests and public scanner 404s are excluded by default so traffic stays useful.
- Set FETCHNODE_ENABLED=1 only in the deployed environment; tests and local runs stay off by default.
- Set FETCHNODE_ENVIRONMENT and FETCHNODE_RELEASE from deployment environment variables; the Django snippet exposes matching values to the browser template.
- Keep browser analytics disabled until the cookie banner grants consent, then call FetchNode("consent", {analytics: true}).
- Document FetchNode analytics in the cookie banner and privacy policy. Treat error tracking separately according to the applicable legal basis.
- Headers, bodies, IP addresses, query strings and SQL text are disabled by default; authenticated users contribute only their internal ID.
Create a project for each site
Each project gets its own key, errors, traffic, users, sessions, and top pages.