forge-ops-tracker 0.1.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.
@@ -0,0 +1,103 @@
1
+ """ForgeOps error tracking client.
2
+
3
+ import forge_ops_tracker
4
+ forge_ops_tracker.init(dsn="https://<api_key>@your-forgeops-host/api/v1/events")
5
+
6
+ See the README for Django/Flask integration and what gets captured
7
+ automatically vs. what needs an explicit capture_exception() call.
8
+ """
9
+
10
+ import sys
11
+ import threading
12
+
13
+ from .client import Client
14
+ from .configuration import Configuration
15
+ from .delivery_queue import DeliveryQueue
16
+ from .event_builder import EventBuilder
17
+ from .reporter import Reporter
18
+
19
+ __all__ = ["Configuration", "capture_exception", "init"]
20
+
21
+ _configuration = None
22
+ _reporter = None
23
+ _lock = threading.Lock()
24
+ _original_excepthook = None
25
+
26
+
27
+ def _state():
28
+ global _configuration, _reporter
29
+ if _reporter is None:
30
+ with _lock:
31
+ if _reporter is None:
32
+ _configuration = Configuration()
33
+ client = Client(_configuration)
34
+ delivery_queue = DeliveryQueue(_configuration, client)
35
+ _reporter = Reporter(_configuration, EventBuilder(_configuration), delivery_queue)
36
+ return _configuration, _reporter
37
+
38
+
39
+ def init(dsn=None, **overrides):
40
+ """Configure the client. Call once at startup (Django settings.py, or
41
+ right after creating a Flask app). Any Configuration attribute can be
42
+ overridden by keyword, e.g. init(dsn=..., release=..., environment=...).
43
+ """
44
+ configuration, _ = _state()
45
+ if dsn is not None:
46
+ configuration.dsn = dsn
47
+ for key, value in overrides.items():
48
+ if not hasattr(configuration, key):
49
+ raise TypeError(f"Configuration has no attribute {key!r}")
50
+ setattr(configuration, key, value)
51
+
52
+ if configuration.install_excepthook:
53
+ _install_excepthook()
54
+
55
+ return configuration
56
+
57
+
58
+ def capture_exception(exc=None, context=None):
59
+ """Report an exception you've already caught. `exc` defaults to
60
+ whichever exception is currently being handled, so this can usually
61
+ just be called as capture_exception() from inside an `except:` block."""
62
+ if exc is None:
63
+ exc = sys.exc_info()[1]
64
+ if exc is None:
65
+ return
66
+
67
+ _, reporter = _state()
68
+ reporter.report(exc, context=context)
69
+
70
+
71
+ def _install_excepthook():
72
+ # Reports anything that crashes the whole interpreter (a plain
73
+ # script, a management command, a worker's own top-level loop) with
74
+ # no further wiring -- the same "unhandled needs no wiring" case
75
+ # Rails.error/ASP.NET Core's middleware cover automatically. This does
76
+ # *not* catch a web request's unhandled exception under a WSGI
77
+ # server -- Django/Flask catch that themselves before it ever reaches
78
+ # here, which is what the integrations in forge_ops_tracker.integrations
79
+ # are for.
80
+ global _original_excepthook
81
+ if _original_excepthook is not None:
82
+ return # already installed
83
+
84
+ _original_excepthook = sys.excepthook
85
+
86
+ def _excepthook(exc_type, exc_value, exc_tb):
87
+ try:
88
+ capture_exception(exc_value)
89
+ finally:
90
+ _original_excepthook(exc_type, exc_value, exc_tb)
91
+
92
+ sys.excepthook = _excepthook
93
+
94
+
95
+ def _reset_for_testing():
96
+ """Not part of the public API -- resets module-level state between
97
+ test cases."""
98
+ global _configuration, _reporter, _original_excepthook
99
+ if _original_excepthook is not None:
100
+ sys.excepthook = _original_excepthook
101
+ _configuration = None
102
+ _reporter = None
103
+ _original_excepthook = None
@@ -0,0 +1,41 @@
1
+ """Delivers one payload over HTTP. Every failure mode -- DNS, connection,
2
+ timeout, TLS, a non-2xx response -- is caught here and turned into a
3
+ `False` return rather than a raised exception, since a broken or
4
+ unreachable tracker must never be able to break the host app. Ported
5
+ from gems/forge_ops_tracker/lib/forge_ops_tracker/client.rb.
6
+
7
+ Uses only the standard library (urllib), not `requests` -- same reason
8
+ the Ruby gem uses plain Net::HTTP rather than a gem dependency: this has
9
+ to work in any host app without adding a dependency of its own.
10
+ """
11
+
12
+ import json
13
+ import urllib.error
14
+ import urllib.request
15
+
16
+
17
+ class Client:
18
+ def __init__(self, configuration):
19
+ self._configuration = configuration
20
+
21
+ def deliver(self, payload):
22
+ uri = self._configuration.ingestion_uri()
23
+ if not uri:
24
+ return False
25
+
26
+ try:
27
+ body = json.dumps(payload).encode("utf-8")
28
+ request = urllib.request.Request(
29
+ uri,
30
+ data=body,
31
+ method="POST",
32
+ headers={
33
+ "Authorization": f"Bearer {self._configuration.api_key}",
34
+ "Content-Type": "application/json",
35
+ },
36
+ )
37
+ with urllib.request.urlopen(request, timeout=self._configuration.timeout) as response:
38
+ return 200 <= response.status < 300
39
+ except Exception as e: # noqa: BLE001 -- deliberately broad: a broken/unreachable tracker must never break the host app
40
+ self._configuration.logger.debug("[forge_ops_tracker] delivery failed: %s: %s", type(e).__name__, e)
41
+ return False
@@ -0,0 +1,82 @@
1
+ """Holds a single ForgeOps DSN plus everything else the client needs to
2
+ build and deliver events. Mirrors gems/forge_ops_tracker's Configuration --
3
+ a single Sentry-style DSN string carries both the ingestion URL and the
4
+ project's api_key: "https://<api_key>@host/api/v1/events".
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import socket
10
+ from urllib.parse import unquote, urlsplit, urlunsplit
11
+
12
+
13
+ class Configuration:
14
+ def __init__(self):
15
+ self.dsn = os.environ.get("FORGE_OPS_DSN")
16
+ self.environment = os.environ.get("FORGE_OPS_ENVIRONMENT", "development")
17
+ self.release = os.environ.get("FORGE_OPS_RELEASE")
18
+ self.server_name = _safe_hostname()
19
+
20
+ # Used to decide whether a backtrace frame is "in_app": a frame's
21
+ # file path is compared against this root. Unlike the .NET SDK
22
+ # (where a compiled assembly's file path never matches its
23
+ # original source location), Python runs interpreted directly
24
+ # from real .py files on disk, so file-path matching is the
25
+ # correct approach here too, same as the Ruby gem's Rails.root
26
+ # comparison. Defaults to the current working directory; set
27
+ # explicitly if that doesn't match your app's actual layout (a
28
+ # WSGI server started from a different directory, for instance).
29
+ self.app_root = os.getcwd()
30
+
31
+ self.enabled_environments = {"production", "staging"}
32
+ self.queue_size = 1000
33
+ self.timeout = 2.0 # seconds
34
+ self.scrub_pii = True
35
+ self.logger = logging.getLogger("forge_ops_tracker")
36
+
37
+ # Reports anything that crashes the whole interpreter (a plain
38
+ # script, a management command) with zero extra wiring, the same
39
+ # way an unhandled Rails/ASP.NET Core request is covered
40
+ # automatically elsewhere -- see _install_excepthook in __init__.py.
41
+ # Doesn't change program behavior (the original hook still runs
42
+ # afterward), so on by default is safe; set False to opt out.
43
+ self.install_excepthook = True
44
+
45
+ @property
46
+ def api_key(self):
47
+ parsed = self._parsed_dsn()
48
+ if parsed is None or not parsed.username:
49
+ return None
50
+ return unquote(parsed.username)
51
+
52
+ def ingestion_uri(self):
53
+ """The ingestion URL with credentials stripped out (they travel as
54
+ the Authorization header instead, not embedded in the request
55
+ URI)."""
56
+ parsed = self._parsed_dsn()
57
+ if parsed is None:
58
+ return None
59
+
60
+ netloc = parsed.hostname or ""
61
+ if parsed.port:
62
+ netloc = f"{netloc}:{parsed.port}"
63
+ return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, ""))
64
+
65
+ def is_enabled(self):
66
+ return bool(self.dsn) and bool(self.api_key) and self.environment in self.enabled_environments
67
+
68
+ def _parsed_dsn(self):
69
+ if not self.dsn:
70
+ return None
71
+ # urlsplit never raises on malformed input (verified directly,
72
+ # not assumed) -- a bad DSN just parses to an empty netloc, which
73
+ # api_key/ingestion_uri already handle by returning None.
74
+ result = urlsplit(self.dsn)
75
+ return result if result.scheme else None
76
+
77
+
78
+ def _safe_hostname():
79
+ try:
80
+ return socket.gethostname()
81
+ except Exception: # noqa: BLE001 -- hostname lookup must never be able to crash the host app
82
+ return None
@@ -0,0 +1,55 @@
1
+ """A small in-process background thread + bounded queue, so delivery
2
+ never blocks the caller that raised the error and never depends on the
3
+ host app having any particular job backend configured. Ported from
4
+ gems/forge_ops_tracker/lib/forge_ops_tracker/delivery_queue.rb.
5
+
6
+ The worker thread is started lazily, on first push, not at import/
7
+ construction time -- deliberately mirroring the Ruby gem rather than the
8
+ .NET SDK's eager start. Gunicorn (prefork) and uWSGI commonly fork worker
9
+ processes *after* the application (and this module) has already loaded,
10
+ which would leave an eagerly-started thread dead in every forked child --
11
+ the exact hazard the Ruby gem's own lazy start avoids for Puma. Starting
12
+ fresh on first push means each forked worker gets its own live thread
13
+ regardless of when it was forked relative to import time.
14
+ """
15
+
16
+ import queue
17
+ import threading
18
+
19
+
20
+ class DeliveryQueue:
21
+ def __init__(self, configuration, client):
22
+ self._configuration = configuration
23
+ self._client = client
24
+ self._queue = queue.Queue(maxsize=max(1, configuration.queue_size))
25
+ self._thread = None
26
+ self._start_lock = threading.Lock()
27
+
28
+ def push(self, payload):
29
+ self._ensure_worker_started()
30
+ try:
31
+ self._queue.put_nowait(payload)
32
+ return True
33
+ except queue.Full:
34
+ self._configuration.logger.debug("[forge_ops_tracker] delivery queue full, dropping event")
35
+ return False
36
+
37
+ def _ensure_worker_started(self):
38
+ if self._thread is not None and self._thread.is_alive():
39
+ return
40
+
41
+ with self._start_lock:
42
+ if self._thread is not None and self._thread.is_alive():
43
+ return
44
+ self._thread = threading.Thread(target=self._run, daemon=True)
45
+ self._thread.start()
46
+
47
+ def _run(self):
48
+ while True:
49
+ payload = self._queue.get()
50
+ try:
51
+ self._client.deliver(payload)
52
+ except Exception as e: # noqa: BLE001 -- per-item, so one bad delivery can't kill the worker for every event after it
53
+ # Per-item, not wrapping the whole loop: one bad delivery
54
+ # must not kill the worker for every event after it.
55
+ self._configuration.logger.debug("[forge_ops_tracker] delivery worker error: %s: %s", type(e).__name__, e)
@@ -0,0 +1,88 @@
1
+ """Turns a raised exception into the payload shape the ingestion API
2
+ expects. Ported from
3
+ gems/forge_ops_tracker/lib/forge_ops_tracker/event_builder.rb --
4
+ backtrace frames come from traceback.extract_tb rather than regex-parsing
5
+ MRI backtrace lines, but the resulting shape (file/line/method/in_app)
6
+ is the same.
7
+ """
8
+
9
+ import sysconfig
10
+ import traceback
11
+ from datetime import datetime, timezone
12
+
13
+ from . import pii_scrubber
14
+
15
+ MAX_FRAMES = 500
16
+
17
+ _STDLIB_PATH = sysconfig.get_path("stdlib")
18
+
19
+
20
+ class EventBuilder:
21
+ def __init__(self, configuration):
22
+ self._configuration = configuration
23
+
24
+ def build(self, exc, context=None):
25
+ payload = {
26
+ "exception_class": _exception_class_name(exc),
27
+ "message": str(exc),
28
+ "backtrace": self._backtrace(exc),
29
+ "occurred_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
30
+ "environment": self._configuration.environment,
31
+ "release": self._configuration.release,
32
+ "server_name": self._configuration.server_name,
33
+ "context": dict(context) if context else {},
34
+ "tags": {},
35
+ }
36
+
37
+ if self._configuration.scrub_pii:
38
+ payload = self._scrub(payload)
39
+
40
+ return payload
41
+
42
+ # exception_class/occurred_at/environment/release/server_name are left
43
+ # alone -- structured fields this client or the host app sets
44
+ # deliberately, not free text an exception or its context could
45
+ # accidentally spill sensitive data into.
46
+ def _scrub(self, payload):
47
+ payload = dict(payload)
48
+ payload["message"] = pii_scrubber.scrub_string(payload["message"])
49
+ payload["backtrace"] = [
50
+ {
51
+ **frame,
52
+ "file": pii_scrubber.scrub_string(frame["file"]) if frame["file"] else frame["file"],
53
+ "method": pii_scrubber.scrub_string(frame["method"]) if frame["method"] else frame["method"],
54
+ }
55
+ for frame in payload["backtrace"]
56
+ ]
57
+ payload["context"] = pii_scrubber.scrub(payload["context"])
58
+ payload["tags"] = pii_scrubber.scrub(payload["tags"])
59
+ return payload
60
+
61
+ def _backtrace(self, exc):
62
+ frames = traceback.extract_tb(exc.__traceback__)[:MAX_FRAMES]
63
+ return [
64
+ {
65
+ "file": frame.filename,
66
+ "line": frame.lineno,
67
+ "method": frame.name,
68
+ "in_app": self._is_in_app(frame.filename),
69
+ }
70
+ for frame in frames
71
+ ]
72
+
73
+ def _is_in_app(self, filename):
74
+ root = self._configuration.app_root
75
+ if not root or not filename:
76
+ return False
77
+ if not filename.startswith(root):
78
+ return False
79
+ if _STDLIB_PATH and filename.startswith(_STDLIB_PATH):
80
+ return False
81
+ return "site-packages" not in filename and "dist-packages" not in filename
82
+
83
+
84
+ def _exception_class_name(exc):
85
+ cls = type(exc)
86
+ if cls.__module__ in ("builtins", "__main__"):
87
+ return cls.__name__
88
+ return f"{cls.__module__}.{cls.__name__}"
File without changes
@@ -0,0 +1,37 @@
1
+ """Django middleware. Add to MIDDLEWARE in settings.py:
2
+
3
+ MIDDLEWARE = [
4
+ ...,
5
+ "forge_ops_tracker.integrations.django.ForgeOpsTrackerMiddleware",
6
+ ]
7
+
8
+ Not imported by forge_ops_tracker/__init__.py -- Django is an optional
9
+ dependency, so importing this submodule specifically is what pulls it in,
10
+ rather than every forge_ops_tracker user needing Django installed.
11
+ """
12
+
13
+ from .. import capture_exception
14
+
15
+
16
+ class ForgeOpsTrackerMiddleware:
17
+ def __init__(self, get_response):
18
+ self.get_response = get_response
19
+
20
+ def __call__(self, request):
21
+ return self.get_response(request)
22
+
23
+ def process_exception(self, request, exception):
24
+ # Reports, then returns None -- Django's own exception handling
25
+ # (DEBUG page, a custom handler500, whatever's configured) behaves
26
+ # exactly as if this middleware weren't installed. Only fires for
27
+ # an exception that actually escaped the view uncaught; anything
28
+ # your own code already catches never reaches here at all.
29
+ capture_exception(exception, context=_request_context(request))
30
+
31
+
32
+ def _request_context(request):
33
+ return {
34
+ "path": request.path,
35
+ "method": request.method,
36
+ "query_string": request.META.get("QUERY_STRING", ""),
37
+ }
@@ -0,0 +1,40 @@
1
+ """Flask integration. Call once after creating the app:
2
+
3
+ from forge_ops_tracker.integrations.flask import init_flask
4
+ app = Flask(__name__)
5
+ init_flask(app)
6
+
7
+ Not imported by forge_ops_tracker/__init__.py -- Flask is an optional
8
+ dependency, so importing this submodule specifically is what pulls it
9
+ in, rather than every forge_ops_tracker user needing Flask installed.
10
+ """
11
+
12
+ from flask import request
13
+ from flask.signals import got_request_exception
14
+
15
+ from .. import capture_exception
16
+
17
+
18
+ def init_flask(app):
19
+ def _handle(sender, exception, **extra):
20
+ # Fires for a view's unhandled exception regardless of whether
21
+ # Flask's own error handling ultimately produces a response --
22
+ # reporting here doesn't change how Flask handles it afterward.
23
+ # Only fires for an exception that actually escaped the view
24
+ # uncaught; anything your own code already catches never reaches
25
+ # here at all.
26
+ capture_exception(exception, context=_request_context())
27
+
28
+ got_request_exception.connect(_handle, app)
29
+ # Blinker (Flask's signal library) uses weak references by default --
30
+ # keep a strong reference on the app itself so this handler isn't
31
+ # garbage collected right after init_flask returns.
32
+ app._forge_ops_tracker_signal_handler = _handle
33
+
34
+
35
+ def _request_context():
36
+ return {
37
+ "path": request.path,
38
+ "method": request.method,
39
+ "query_string": request.query_string.decode("utf-8", "replace"),
40
+ }
@@ -0,0 +1,67 @@
1
+ """Redacts likely-sensitive content out of a payload before it ever leaves
2
+ this process -- the same patterns ForgeOps itself applies again on
3
+ arrival (defense in depth: this layer keeps the data off the wire and out
4
+ of any request logging in between; the server-side layer is what actually
5
+ protects the database, and doesn't depend on every reporting app running
6
+ an up-to-date version of this client). Ported from
7
+ gems/forge_ops_tracker/lib/forge_ops_tracker/pii_scrubber.rb -- kept
8
+ standalone and dependency-free here for the same reason as the Ruby
9
+ original: this has to work in any host app regardless of what's reporting
10
+ into it.
11
+
12
+ Can be turned off via Configuration.scrub_pii = False for a host app that
13
+ already scrubs its own data before it ever reaches exception context, or
14
+ that has its own reasons to want the raw payload. Off by default is not
15
+ an option: the safe default has to be "on."
16
+ """
17
+
18
+ import re
19
+
20
+ REDACTED = "[FILTERED]"
21
+
22
+ SENSITIVE_KEYS = {
23
+ "password", "passwd", "pwd",
24
+ "secret", "apisecret", "clientsecret", "secretkey",
25
+ "token", "accesstoken", "refreshtoken", "apikey", "apitoken", "authorization", "authtoken", "bearer",
26
+ "sessiontoken", "csrftoken",
27
+ "creditcard", "cardnumber", "cardnum", "cvv", "cvv2", "cvc",
28
+ "ssn", "socialsecuritynumber", "socialsecurity",
29
+ "privatekey",
30
+ }
31
+
32
+ _PATTERNS = (
33
+ ("EMAIL", re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")),
34
+ ("SSN", re.compile(r"\b\d{3}-\d{2}-\d{4}\b")),
35
+ ("CREDIT CARD", re.compile(r"\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{1,4}\b")),
36
+ ("BEARER TOKEN", re.compile(r"\bBearer\s+[A-Za-z0-9\-._~+/]+=*", re.IGNORECASE)),
37
+ ("JWT", re.compile(r"\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")),
38
+ ("AWS KEY", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
39
+ ("STRIPE KEY", re.compile(r"\b[sr]k_(?:live|test)_[A-Za-z0-9]{10,}\b")),
40
+ ("GITHUB TOKEN", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b")),
41
+ )
42
+
43
+
44
+ def scrub(value, key=None):
45
+ if _is_sensitive_key(key) and value is not None:
46
+ return REDACTED
47
+
48
+ if isinstance(value, dict):
49
+ return {k: scrub(v, key=k) for k, v in value.items()}
50
+ if isinstance(value, (list, tuple)):
51
+ return [scrub(v, key=key) for v in value]
52
+ if isinstance(value, str):
53
+ return scrub_string(value)
54
+ return value
55
+
56
+
57
+ def scrub_string(text):
58
+ for label, pattern in _PATTERNS:
59
+ text = pattern.sub(f"[{label} FILTERED]", text)
60
+ return text
61
+
62
+
63
+ def _is_sensitive_key(key):
64
+ if not key:
65
+ return False
66
+ normalized = "".join(ch for ch in str(key).lower() if ch.isalnum())
67
+ return any(sensitive in normalized for sensitive in SENSITIVE_KEYS)
@@ -0,0 +1,24 @@
1
+ """Ties Configuration, EventBuilder, and DeliveryQueue together into the
2
+ one thing callers actually need: report an exception. Mirrors
3
+ gems/forge_ops_tracker's ErrorSubscriber#report -- never raises. An error
4
+ reporter that itself raises while reporting an error is the worst
5
+ possible failure mode, so every path here is wrapped to guarantee this
6
+ never propagates back into the host app.
7
+ """
8
+
9
+
10
+ class Reporter:
11
+ def __init__(self, configuration, event_builder, delivery_queue):
12
+ self._configuration = configuration
13
+ self._event_builder = event_builder
14
+ self._delivery_queue = delivery_queue
15
+
16
+ def report(self, exc, context=None):
17
+ try:
18
+ if not self._configuration.is_enabled():
19
+ return
20
+
21
+ payload = self._event_builder.build(exc, context=context)
22
+ self._delivery_queue.push(payload)
23
+ except Exception as e: # noqa: BLE001 -- deliberately broad: this must never raise back into the host app
24
+ self._configuration.logger.debug("[forge_ops_tracker] report failed: %s: %s", type(e).__name__, e)
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: forge-ops-tracker
3
+ Version: 0.1.0
4
+ Summary: ForgeOps error tracking client: captures unhandled exceptions (Django/Flask middleware, plus explicit capture anywhere else) and delivers them to a ForgeOps instance over HTTP.
5
+ Author: ForgeOps
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://getforgeops.net
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Topic :: System :: Logging
11
+ Classifier: Framework :: Django
12
+ Classifier: Framework :: Flask
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE.txt
16
+ Provides-Extra: django
17
+ Requires-Dist: django>=4.2; extra == "django"
18
+ Provides-Extra: flask
19
+ Requires-Dist: flask>=2.3; extra == "flask"
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=8; extra == "test"
22
+ Requires-Dist: pytest-django>=4.8; extra == "test"
23
+ Requires-Dist: flask>=2.3; extra == "test"
24
+ Requires-Dist: django>=4.2; extra == "test"
25
+ Dynamic: license-file
26
+
27
+ # forge-ops-tracker
28
+
29
+ Python error reporting client for a private, self-hosted [ForgeOps](../../) tracker instance.
30
+ Requires Python 3.9+. A from-scratch port of [`gems/forge_ops_tracker`](../../gems/forge_ops_tracker)
31
+ (the Rails client) -- see that gem's README for the shared design rationale; this document only
32
+ covers what's Python-specific.
33
+
34
+ ## Installation
35
+
36
+ Not yet published to PyPI -- install directly from this path (or a local checkout, once split into
37
+ its own repo):
38
+
39
+ ```bash
40
+ pip install -e path/to/forge_ops/sdks/python
41
+ ```
42
+
43
+ For Django or Flask integration, install the matching extra:
44
+
45
+ ```bash
46
+ pip install -e "path/to/forge_ops/sdks/python[django]"
47
+ pip install -e "path/to/forge_ops/sdks/python[flask]"
48
+ ```
49
+
50
+ ## Configuration
51
+
52
+ Set a DSN (from a project's settings page in ForgeOps), either via the `FORGE_OPS_DSN` environment
53
+ variable or explicitly:
54
+
55
+ ```python
56
+ import forge_ops_tracker
57
+
58
+ forge_ops_tracker.init(
59
+ dsn="https://<api_key>@your-forgeops-host/api/v1/events", # or leave unset to read FORGE_OPS_DSN
60
+ release="...",
61
+ environment="production",
62
+ )
63
+ ```
64
+
65
+ Call `init()` once at startup -- Django's `settings.py`, or right after creating a Flask app. Any
66
+ `Configuration` attribute can be overridden by keyword.
67
+
68
+ ### Django
69
+
70
+ ```python
71
+ # settings.py
72
+ import forge_ops_tracker
73
+
74
+ forge_ops_tracker.init(dsn="https://<api_key>@your-forgeops-host/api/v1/events")
75
+
76
+ MIDDLEWARE = [
77
+ ...,
78
+ "forge_ops_tracker.integrations.django.ForgeOpsTrackerMiddleware",
79
+ ]
80
+ ```
81
+
82
+ ### Flask
83
+
84
+ ```python
85
+ from flask import Flask
86
+ import forge_ops_tracker
87
+ from forge_ops_tracker.integrations.flask import init_flask
88
+
89
+ forge_ops_tracker.init(dsn="https://<api_key>@your-forgeops-host/api/v1/events")
90
+
91
+ app = Flask(__name__)
92
+ init_flask(app)
93
+ ```
94
+
95
+ ## What gets reported automatically, and what doesn't
96
+
97
+ **An exception that crashes a request needs no further wiring at all.** The Django middleware's
98
+ `process_exception` hook and Flask's `got_request_exception` signal both fire for anything that
99
+ propagates uncaught out of a view, then let the framework handle it exactly as if this client
100
+ weren't installed.
101
+
102
+ **An exception your own code catches and handles is different -- neither integration ever sees
103
+ it**, since it never propagates far enough to reach either hook:
104
+
105
+ ```python
106
+ try:
107
+ charge_card(order)
108
+ except CardError as e:
109
+ logger.warning("card declined: %s", e)
110
+ # ForgeOps never sees this -- caught locally, never reaches the
111
+ # middleware/signal at all.
112
+ ```
113
+
114
+ There's no Django/Flask-wide equivalent to Rails' `Rails.error.handle` here -- report it explicitly
115
+ instead, right at the catch site:
116
+
117
+ ```python
118
+ except CardError as e:
119
+ forge_ops_tracker.capture_exception(e, context={"order_id": order.id})
120
+ logger.warning("card declined: %s", e)
121
+ ```
122
+
123
+ Called with no arguments, `capture_exception()` picks up whichever exception is currently being
124
+ handled (same as a bare `raise` inside an `except:` block), so it usually reads as just
125
+ `forge_ops_tracker.capture_exception()` from inside the block that already caught it.
126
+
127
+ ### Outside a web request (scripts, management commands, workers)
128
+
129
+ `init()` also installs a `sys.excepthook` wrapper by default (`Configuration.install_excepthook`,
130
+ `True` unless set otherwise), which reports anything that crashes the whole interpreter -- a plain
131
+ script, a Django management command, a worker's own top-level loop -- with no wiring needed, the
132
+ same "unhandled needs no wiring" case the Django/Flask integrations cover for web requests. It
133
+ still calls whatever `sys.excepthook` was already installed afterward, so it never changes program
134
+ behavior. This does **not** catch a web request's unhandled exception under a real WSGI server
135
+ (Gunicorn/uWSGI catch that themselves per-request, long before it would ever reach the interpreter
136
+ level) -- that's what the Django/Flask integrations are for.
137
+
138
+ Delivery happens on a background thread with a bounded queue and a short per-request HTTP timeout
139
+ (`Configuration.timeout`, 2s default). Every failure mode -- network errors, timeouts, a full queue,
140
+ a malformed DSN -- is caught and dropped rather than raised, so a broken or unreachable tracker can
141
+ never take down the host app. The worker thread starts lazily, on first push, not at import time --
142
+ Gunicorn (prefork) and uWSGI commonly fork worker processes *after* the application has already
143
+ loaded, which would leave an eagerly-started thread dead in every forked child; starting fresh on
144
+ first push means each forked worker gets its own live thread regardless of when it was forked
145
+ relative to import.
146
+
147
+ ## `in_app` backtrace frames
148
+
149
+ Unlike the .NET SDK (where a compiled assembly's file path never matches its original source
150
+ location), Python runs interpreted directly from real `.py` files on disk, so file-path matching
151
+ against `Configuration.app_root` works the same way it does in the Ruby gem's `Rails.root`
152
+ comparison. Defaults to the current working directory; set it explicitly if that doesn't match your
153
+ app's actual layout (a WSGI server started from a different directory than your app's root, for
154
+ instance). Standard-library and installed-package (`site-packages`/`dist-packages`) frames are
155
+ never marked `in_app`, regardless of `app_root`.
156
+
157
+ ## PII scrubbing
158
+
159
+ Same behavior as the Ruby gem: the message, backtrace, and any context/tags you attach are scanned
160
+ for likely personal data -- email addresses, formatted SSNs/credit cards, known API key/token
161
+ formats, and anything under a suspiciously-named key (`password`, `api_key`, `ssn`, and similar) --
162
+ and redacted before the payload ever leaves this process. ForgeOps itself scrubs again on arrival
163
+ regardless, so this is a second, earlier layer, not the only one.
164
+
165
+ To disable it:
166
+
167
+ ```python
168
+ forge_ops_tracker.init(dsn="...", scrub_pii=False)
169
+ ```
170
+
171
+ ## Running the tests
172
+
173
+ ```bash
174
+ cd sdks/python
175
+ python3 -m venv .venv
176
+ ./.venv/bin/pip install -e ".[test]"
177
+ ./.venv/bin/python -m pytest
178
+ ./.venv/bin/ruff check src tests
179
+ ```
@@ -0,0 +1,15 @@
1
+ forge_ops_tracker/__init__.py,sha256=JMaWtRDUCJChTU6l4I6V3mx6zuhzJF3MyfHvlhvTe04,3422
2
+ forge_ops_tracker/client.py,sha256=ozwtvEoFCfZhCulU1CPcxJCsPNuNzr5lS7_ez8ZAVNg,1638
3
+ forge_ops_tracker/configuration.py,sha256=shHovnBAoyT6E9NR_AYgCqhUpKNs93GYq7v_HC87JSo,3373
4
+ forge_ops_tracker/delivery_queue.py,sha256=0KPvTb0soojKgT5S_sBsyLL2L-GsOUo-VlyipFqU1ug,2361
5
+ forge_ops_tracker/event_builder.py,sha256=ge3_2warzzU6QLjFx0VYfGvUgDTk7_Y8TBZvoDluQk0,3072
6
+ forge_ops_tracker/pii_scrubber.py,sha256=J-1AdYR6uauE8Qfl_OYH7tUG-h5-lCVo4FIzG2gLqaw,2648
7
+ forge_ops_tracker/reporter.py,sha256=CHhxHyHSEc0F3BzSYc8d1_pImn649no1c-MH6eYCM9o,1088
8
+ forge_ops_tracker/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ forge_ops_tracker/integrations/django.py,sha256=368U_Ve5qcRyNxpXgM9HLbzLtwyTX5faPRsVeIVD42Q,1276
10
+ forge_ops_tracker/integrations/flask.py,sha256=Sxrd5wr7u_TzBSZ2FgV0WhgHfdKZZ_DSdDpAvOzfHjU,1486
11
+ forge_ops_tracker-0.1.0.dist-info/licenses/LICENSE.txt,sha256=hw7Ta2pN2nuLxH7hM_WS3-8-7uVVeT7gHRAoTqVk8lo,1065
12
+ forge_ops_tracker-0.1.0.dist-info/METADATA,sha256=xwlo7yQy1kM7aKojN_7WbJbbK_JeNsai7JUaKtKIk6o,6908
13
+ forge_ops_tracker-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
14
+ forge_ops_tracker-0.1.0.dist-info/top_level.txt,sha256=XhRWmUGp5jtH249ix2hvGJ_8LJbu1F_jFtU4CsPW1KY,18
15
+ forge_ops_tracker-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ForgeOps
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ forge_ops_tracker