forge-ops-tracker 0.1.0__tar.gz

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.
Files changed (29) hide show
  1. forge_ops_tracker-0.1.0/LICENSE.txt +21 -0
  2. forge_ops_tracker-0.1.0/PKG-INFO +179 -0
  3. forge_ops_tracker-0.1.0/README.md +153 -0
  4. forge_ops_tracker-0.1.0/pyproject.toml +42 -0
  5. forge_ops_tracker-0.1.0/setup.cfg +4 -0
  6. forge_ops_tracker-0.1.0/src/forge_ops_tracker/__init__.py +103 -0
  7. forge_ops_tracker-0.1.0/src/forge_ops_tracker/client.py +41 -0
  8. forge_ops_tracker-0.1.0/src/forge_ops_tracker/configuration.py +82 -0
  9. forge_ops_tracker-0.1.0/src/forge_ops_tracker/delivery_queue.py +55 -0
  10. forge_ops_tracker-0.1.0/src/forge_ops_tracker/event_builder.py +88 -0
  11. forge_ops_tracker-0.1.0/src/forge_ops_tracker/integrations/__init__.py +0 -0
  12. forge_ops_tracker-0.1.0/src/forge_ops_tracker/integrations/django.py +37 -0
  13. forge_ops_tracker-0.1.0/src/forge_ops_tracker/integrations/flask.py +40 -0
  14. forge_ops_tracker-0.1.0/src/forge_ops_tracker/pii_scrubber.py +67 -0
  15. forge_ops_tracker-0.1.0/src/forge_ops_tracker/reporter.py +24 -0
  16. forge_ops_tracker-0.1.0/src/forge_ops_tracker.egg-info/PKG-INFO +179 -0
  17. forge_ops_tracker-0.1.0/src/forge_ops_tracker.egg-info/SOURCES.txt +27 -0
  18. forge_ops_tracker-0.1.0/src/forge_ops_tracker.egg-info/dependency_links.txt +1 -0
  19. forge_ops_tracker-0.1.0/src/forge_ops_tracker.egg-info/requires.txt +12 -0
  20. forge_ops_tracker-0.1.0/src/forge_ops_tracker.egg-info/top_level.txt +1 -0
  21. forge_ops_tracker-0.1.0/tests/test_client.py +82 -0
  22. forge_ops_tracker-0.1.0/tests/test_configuration.py +63 -0
  23. forge_ops_tracker-0.1.0/tests/test_delivery_queue.py +76 -0
  24. forge_ops_tracker-0.1.0/tests/test_event_builder.py +117 -0
  25. forge_ops_tracker-0.1.0/tests/test_init.py +120 -0
  26. forge_ops_tracker-0.1.0/tests/test_integrations_django.py +43 -0
  27. forge_ops_tracker-0.1.0/tests/test_integrations_flask.py +51 -0
  28. forge_ops_tracker-0.1.0/tests/test_pii_scrubber.py +33 -0
  29. forge_ops_tracker-0.1.0/tests/test_reporter.py +64 -0
@@ -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,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,153 @@
1
+ # forge-ops-tracker
2
+
3
+ Python error reporting client for a private, self-hosted [ForgeOps](../../) tracker instance.
4
+ Requires Python 3.9+. A from-scratch port of [`gems/forge_ops_tracker`](../../gems/forge_ops_tracker)
5
+ (the Rails client) -- see that gem's README for the shared design rationale; this document only
6
+ covers what's Python-specific.
7
+
8
+ ## Installation
9
+
10
+ Not yet published to PyPI -- install directly from this path (or a local checkout, once split into
11
+ its own repo):
12
+
13
+ ```bash
14
+ pip install -e path/to/forge_ops/sdks/python
15
+ ```
16
+
17
+ For Django or Flask integration, install the matching extra:
18
+
19
+ ```bash
20
+ pip install -e "path/to/forge_ops/sdks/python[django]"
21
+ pip install -e "path/to/forge_ops/sdks/python[flask]"
22
+ ```
23
+
24
+ ## Configuration
25
+
26
+ Set a DSN (from a project's settings page in ForgeOps), either via the `FORGE_OPS_DSN` environment
27
+ variable or explicitly:
28
+
29
+ ```python
30
+ import forge_ops_tracker
31
+
32
+ forge_ops_tracker.init(
33
+ dsn="https://<api_key>@your-forgeops-host/api/v1/events", # or leave unset to read FORGE_OPS_DSN
34
+ release="...",
35
+ environment="production",
36
+ )
37
+ ```
38
+
39
+ Call `init()` once at startup -- Django's `settings.py`, or right after creating a Flask app. Any
40
+ `Configuration` attribute can be overridden by keyword.
41
+
42
+ ### Django
43
+
44
+ ```python
45
+ # settings.py
46
+ import forge_ops_tracker
47
+
48
+ forge_ops_tracker.init(dsn="https://<api_key>@your-forgeops-host/api/v1/events")
49
+
50
+ MIDDLEWARE = [
51
+ ...,
52
+ "forge_ops_tracker.integrations.django.ForgeOpsTrackerMiddleware",
53
+ ]
54
+ ```
55
+
56
+ ### Flask
57
+
58
+ ```python
59
+ from flask import Flask
60
+ import forge_ops_tracker
61
+ from forge_ops_tracker.integrations.flask import init_flask
62
+
63
+ forge_ops_tracker.init(dsn="https://<api_key>@your-forgeops-host/api/v1/events")
64
+
65
+ app = Flask(__name__)
66
+ init_flask(app)
67
+ ```
68
+
69
+ ## What gets reported automatically, and what doesn't
70
+
71
+ **An exception that crashes a request needs no further wiring at all.** The Django middleware's
72
+ `process_exception` hook and Flask's `got_request_exception` signal both fire for anything that
73
+ propagates uncaught out of a view, then let the framework handle it exactly as if this client
74
+ weren't installed.
75
+
76
+ **An exception your own code catches and handles is different -- neither integration ever sees
77
+ it**, since it never propagates far enough to reach either hook:
78
+
79
+ ```python
80
+ try:
81
+ charge_card(order)
82
+ except CardError as e:
83
+ logger.warning("card declined: %s", e)
84
+ # ForgeOps never sees this -- caught locally, never reaches the
85
+ # middleware/signal at all.
86
+ ```
87
+
88
+ There's no Django/Flask-wide equivalent to Rails' `Rails.error.handle` here -- report it explicitly
89
+ instead, right at the catch site:
90
+
91
+ ```python
92
+ except CardError as e:
93
+ forge_ops_tracker.capture_exception(e, context={"order_id": order.id})
94
+ logger.warning("card declined: %s", e)
95
+ ```
96
+
97
+ Called with no arguments, `capture_exception()` picks up whichever exception is currently being
98
+ handled (same as a bare `raise` inside an `except:` block), so it usually reads as just
99
+ `forge_ops_tracker.capture_exception()` from inside the block that already caught it.
100
+
101
+ ### Outside a web request (scripts, management commands, workers)
102
+
103
+ `init()` also installs a `sys.excepthook` wrapper by default (`Configuration.install_excepthook`,
104
+ `True` unless set otherwise), which reports anything that crashes the whole interpreter -- a plain
105
+ script, a Django management command, a worker's own top-level loop -- with no wiring needed, the
106
+ same "unhandled needs no wiring" case the Django/Flask integrations cover for web requests. It
107
+ still calls whatever `sys.excepthook` was already installed afterward, so it never changes program
108
+ behavior. This does **not** catch a web request's unhandled exception under a real WSGI server
109
+ (Gunicorn/uWSGI catch that themselves per-request, long before it would ever reach the interpreter
110
+ level) -- that's what the Django/Flask integrations are for.
111
+
112
+ Delivery happens on a background thread with a bounded queue and a short per-request HTTP timeout
113
+ (`Configuration.timeout`, 2s default). Every failure mode -- network errors, timeouts, a full queue,
114
+ a malformed DSN -- is caught and dropped rather than raised, so a broken or unreachable tracker can
115
+ never take down the host app. The worker thread starts lazily, on first push, not at import time --
116
+ Gunicorn (prefork) and uWSGI commonly fork worker processes *after* the application has already
117
+ loaded, which would leave an eagerly-started thread dead in every forked child; starting fresh on
118
+ first push means each forked worker gets its own live thread regardless of when it was forked
119
+ relative to import.
120
+
121
+ ## `in_app` backtrace frames
122
+
123
+ Unlike the .NET SDK (where a compiled assembly's file path never matches its original source
124
+ location), Python runs interpreted directly from real `.py` files on disk, so file-path matching
125
+ against `Configuration.app_root` works the same way it does in the Ruby gem's `Rails.root`
126
+ comparison. Defaults to the current working directory; set it explicitly if that doesn't match your
127
+ app's actual layout (a WSGI server started from a different directory than your app's root, for
128
+ instance). Standard-library and installed-package (`site-packages`/`dist-packages`) frames are
129
+ never marked `in_app`, regardless of `app_root`.
130
+
131
+ ## PII scrubbing
132
+
133
+ Same behavior as the Ruby gem: the message, backtrace, and any context/tags you attach are scanned
134
+ for likely personal data -- email addresses, formatted SSNs/credit cards, known API key/token
135
+ formats, and anything under a suspiciously-named key (`password`, `api_key`, `ssn`, and similar) --
136
+ and redacted before the payload ever leaves this process. ForgeOps itself scrubs again on arrival
137
+ regardless, so this is a second, earlier layer, not the only one.
138
+
139
+ To disable it:
140
+
141
+ ```python
142
+ forge_ops_tracker.init(dsn="...", scrub_pii=False)
143
+ ```
144
+
145
+ ## Running the tests
146
+
147
+ ```bash
148
+ cd sdks/python
149
+ python3 -m venv .venv
150
+ ./.venv/bin/pip install -e ".[test]"
151
+ ./.venv/bin/python -m pytest
152
+ ./.venv/bin/ruff check src tests
153
+ ```
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "forge-ops-tracker"
7
+ version = "0.1.0"
8
+ description = "ForgeOps error tracking client: captures unhandled exceptions (Django/Flask middleware, plus explicit capture anywhere else) and delivers them to a ForgeOps instance over HTTP."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = [ "LICENSE.txt" ]
13
+ authors = [ { name = "ForgeOps" } ]
14
+ dependencies = []
15
+ # No "License :: OSI Approved :: MIT License" classifier -- superseded by the license = "MIT"
16
+ # SPDX expression above (PEP 639); setuptools now rejects having both.
17
+ classifiers = [
18
+ "Programming Language :: Python :: 3",
19
+ "Operating System :: OS Independent",
20
+ "Topic :: System :: Logging",
21
+ "Framework :: Django",
22
+ "Framework :: Flask",
23
+ ]
24
+
25
+ # source_code_uri/repository/changelog deliberately omitted -- the monorepo this currently
26
+ # lives in is private, so a link to it would 404 for anyone outside the team. Add these once
27
+ # this is split into its own public repo (see gems/forge_ops_tracker's gemspec for the same
28
+ # reasoning, applied there first).
29
+ [project.urls]
30
+ Homepage = "https://getforgeops.net"
31
+
32
+ [project.optional-dependencies]
33
+ django = ["django>=4.2"]
34
+ flask = ["flask>=2.3"]
35
+ test = ["pytest>=8", "pytest-django>=4.8", "flask>=2.3", "django>=4.2"]
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
39
+
40
+ [tool.pytest.ini_options]
41
+ DJANGO_SETTINGS_MODULE = "tests.django_settings"
42
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)