uptime-agent 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 (32) hide show
  1. uptime_agent-0.1.0/.gitignore +12 -0
  2. uptime_agent-0.1.0/CHANGELOG.md +28 -0
  3. uptime_agent-0.1.0/LICENSE +21 -0
  4. uptime_agent-0.1.0/PKG-INFO +288 -0
  5. uptime_agent-0.1.0/README.md +258 -0
  6. uptime_agent-0.1.0/docs/wire-protocol-v1.md +456 -0
  7. uptime_agent-0.1.0/pyproject.toml +129 -0
  8. uptime_agent-0.1.0/src/uptimeagent/__init__.py +302 -0
  9. uptime_agent-0.1.0/src/uptimeagent/_bytes.py +50 -0
  10. uptime_agent-0.1.0/src/uptimeagent/_json.py +110 -0
  11. uptime_agent-0.1.0/src/uptimeagent/_version.py +8 -0
  12. uptime_agent-0.1.0/src/uptimeagent/agent.py +772 -0
  13. uptime_agent-0.1.0/src/uptimeagent/backoff.py +87 -0
  14. uptime_agent-0.1.0/src/uptimeagent/buffer.py +89 -0
  15. uptime_agent-0.1.0/src/uptimeagent/config.py +337 -0
  16. uptime_agent-0.1.0/src/uptimeagent/hooks/__init__.py +0 -0
  17. uptime_agent-0.1.0/src/uptimeagent/hooks/asgi.py +127 -0
  18. uptime_agent-0.1.0/src/uptimeagent/hooks/context.py +47 -0
  19. uptime_agent-0.1.0/src/uptimeagent/hooks/exceptions.py +156 -0
  20. uptime_agent-0.1.0/src/uptimeagent/hooks/logging_handler.py +117 -0
  21. uptime_agent-0.1.0/src/uptimeagent/hooks/wsgi.py +160 -0
  22. uptime_agent-0.1.0/src/uptimeagent/instance.py +180 -0
  23. uptime_agent-0.1.0/src/uptimeagent/levels.py +38 -0
  24. uptime_agent-0.1.0/src/uptimeagent/line.py +165 -0
  25. uptime_agent-0.1.0/src/uptimeagent/protocol.py +203 -0
  26. uptime_agent-0.1.0/src/uptimeagent/py.typed +0 -0
  27. uptime_agent-0.1.0/src/uptimeagent/ratelimit.py +51 -0
  28. uptime_agent-0.1.0/src/uptimeagent/redact.py +348 -0
  29. uptime_agent-0.1.0/src/uptimeagent/runtime.py +63 -0
  30. uptime_agent-0.1.0/src/uptimeagent/safe.py +76 -0
  31. uptime_agent-0.1.0/src/uptimeagent/serverless.py +49 -0
  32. uptime_agent-0.1.0/src/uptimeagent/transport.py +184 -0
@@ -0,0 +1,12 @@
1
+ .venv*/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .pytest_cache/
10
+ integration/received.json
11
+ integration/.venv/
12
+ .DS_Store
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ Notable changes to `uptime-agent` (Python; import name `uptimeagent`). Versions are PEP 440; pre-1.0 minor versions
4
+ may break. The wire protocol versions separately: see `docs/wire-protocol-v1.md`.
5
+
6
+ ## 0.1.0 — 19 Oct 2026
7
+
8
+ First release on PyPI, as `uptime-agent`: `pip install uptime-agent`, then
9
+ `import uptimeagent`.
10
+
11
+ ### Added
12
+ - Port of `@uptime-agent/node` 0.1.0: redaction (same defaults, same budgets),
13
+ bounded buffer, batching, gzip NDJSON transport on `urllib` with proxy support, token
14
+ bucket throttling, exponential backoff with full jitter, `Retry-After`.
15
+ - Hooks: stdlib `logging` handler, WSGI and ASGI middleware with ambient request context,
16
+ `sys.excepthook` / `threading.excepthook` / `atexit` capture, AWS Lambda wrapper.
17
+ - `conformance/`: byte-identical copy of the Node fixture set (protocol 1, fixtures 1).
18
+ All 89 cases pass with `"sdk":"python"` substituted in the wire envelopes.
19
+ - Zero runtime dependencies; `py.typed`; CPython 3.9 to 3.13.
20
+
21
+ ### Known limitations
22
+ - `sdkVersion` on the wire is the PEP 440 string (a pre-release reads `0.2.0a0`), not semver. Pending a
23
+ one-line spec amendment.
24
+ - Records from the stdlib logging handler ship as `source="logging"`, a value being
25
+ added to the protocol's closed source set upstream.
26
+ - `process.exit_code` is `null` for `sys.exit(n)`: Python's `atexit` cannot observe it.
27
+ - No console/`print` capture; no asyncio unhandled-exception hook (a recipe is in the README).
28
+ - PyPy and Windows are best-effort: the suite runs there but no release is gated on it.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 UptimeAgent
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,288 @@
1
+ Metadata-Version: 2.4
2
+ Name: uptime-agent
3
+ Version: 0.1.0
4
+ Summary: Ship the errors your Python app already logs to UptimeAgent, so incidents arrive with your own error lines attached. Evidence only, never a vote on uptime. Zero dependencies.
5
+ Project-URL: Homepage, https://uptimeagent.com
6
+ Project-URL: Repository, https://bitbucket.org/webyes-apps/uptime-agent-python
7
+ Project-URL: Changelog, https://bitbucket.org/webyes-apps/uptime-agent-python/src/main/CHANGELOG.md
8
+ Project-URL: Wire protocol, https://bitbucket.org/webyes-apps/uptime-agent-python/src/main/docs/wire-protocol-v1.md
9
+ Author: UptimeAgent
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: asgi,errors,incident,lambda,logging,logs,monitoring,observability,uptime,uptimeagent,wsgi
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: Implementation :: CPython
24
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
25
+ Classifier: Topic :: System :: Logging
26
+ Classifier: Topic :: System :: Monitoring
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.9
29
+ Description-Content-Type: text/markdown
30
+
31
+ # uptime-agent
32
+
33
+ Ship the errors your Python app already logs to UptimeAgent, so an incident arrives
34
+ with your own error lines attached to the outside-in probe evidence.
35
+
36
+ **Status: 0.x.** Minor versions may break until 1.0.0. The wire protocol is frozen and
37
+ executable (`docs/wire-protocol-v1.md`, `conformance/`), and this package passes every
38
+ fixture in it.
39
+
40
+ ```bash
41
+ pip install uptime-agent
42
+ ```
43
+
44
+ The distribution is `uptime-agent`; the import is `uptimeagent`.
45
+
46
+ ```python
47
+ import uptimeagent
48
+
49
+ agent = uptimeagent.uptime_agent(
50
+ service="checkout-api", # required: how the Agents screen groups instances
51
+ serves=["checkout.acme.com"], # the hostnames exactly as they appear in Monitors
52
+ )
53
+ # token comes from UPTIMEAGENT_TOKEN
54
+ ```
55
+
56
+ ## The boundary: evidence, never detection
57
+
58
+ A quiet package is **never** read as downtime, and a package reporting healthy is
59
+ **never** read as uptime. A process that has stopped answering cannot vote on its own
60
+ health, so detection belongs entirely to UptimeAgent's external probe regions. Nothing
61
+ this package sends, or fails to send, can open, close or influence an incident.
62
+
63
+ ## What it never does
64
+
65
+ - **Never raises into your code** after `uptime_agent()` returns. Every public method
66
+ and every hook is wrapped; a failure drops the event and bumps a counter you can read
67
+ with `agent.stats()`.
68
+ - **Never blocks your request path.** `emit` is a redaction walk with a hard budget and
69
+ a buffer append. Network I/O happens on one daemon thread that holds nothing open.
70
+ - **Never grows without bound.** The buffer is hard-capped (lines and bytes), drop-oldest,
71
+ with the in-flight batch still counted against the cap.
72
+ - **Never opens a listening socket, never installs a signal handler, never patches
73
+ `print` or `sys.stdout`.** There is no inbound control path.
74
+ - **Never reads an environment variable outside the documented list**: the
75
+ `UPTIMEAGENT_*` options, the deprecated `UA_TOKEN`, and `uptimeagent.PLATFORM_ENV_VARS`
76
+ (platform detection, proxies, and the three variables the standard library's TLS
77
+ setup reads: `SSL_CERT_FILE`, `SSL_CERT_DIR`, `SSLKEYLOGFILE`). A test wraps
78
+ `os.environ` and fails on any read outside that list.
79
+ - **Zero runtime dependencies.** `pip show uptimeagent` prints an empty `Requires:` line.
80
+ The sdist on PyPI is the exact source that runs in your process.
81
+
82
+ ## What it collects
83
+
84
+ Only what you point it at. Lines at or above `min_level` (default `warn`) from the
85
+ hooks you attach, plus three synthesised kinds: an uncaught exception (`source=exception`),
86
+ a failing HTTP request (`source=http`: method, route, status, duration), and process
87
+ start and exit (`source=process`). Every line is redacted in-process before it is
88
+ buffered.
89
+
90
+ ## Redaction
91
+
92
+ Runs before anything leaves the process, with a fixed node, depth and byte budget so a
93
+ large or cyclic object cannot slow a request. Two categories, two markers:
94
+
95
+ - **Keys**: `[redacted:keys]`. A key is split into words and every contiguous run of
96
+ words is matched against the rules, over the whole flattened path, so `body.card.number`
97
+ matches `cardnumber`. Defaults: `password passwd pwd secret token apikey accesskey
98
+ authorization auth cookie session credential privatekey clientsecret otp cvv cardnumber ssn`.
99
+ - **Patterns**: `[redacted:patterns]` over the message and every string value, in a
100
+ fixed order: bearer tokens, URL credentials, JWTs, AWS access key ids, private key
101
+ blocks, email addresses.
102
+
103
+ Your rules extend the defaults; they never replace them.
104
+
105
+ ```python
106
+ import re
107
+
108
+ agent = uptimeagent.uptime_agent(
109
+ service="checkout-api",
110
+ redact={
111
+ "keys": ["customer_ref"],
112
+ "patterns": [
113
+ re.compile(r"\bACC-\d{8}\b"),
114
+ {"name": "iban", "regex": r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b", "flags": ""},
115
+ ],
116
+ },
117
+ )
118
+ ```
119
+
120
+ Default patterns use ASCII `\b` and `\s` (`re.ASCII`), matching the ingest backstop.
121
+ Your own compiled patterns are used exactly as you compiled them.
122
+
123
+ ## Wiring
124
+
125
+ ### stdlib `logging`
126
+
127
+ ```python
128
+ import logging
129
+
130
+ logging.getLogger().addHandler(agent.logging_handler())
131
+ ```
132
+
133
+ Or `capture_logging=True` (root logger, level from `min_level`) or
134
+ `capture_logging={"level": "WARNING", "logger": "myapp"}` at init. Levels map by name
135
+ (`CRITICAL`/`ERROR` → `error`, `WARNING` → `warn`, everything else → `info`), `extra=`
136
+ fields become attrs, `exc_info` becomes the traceback plus `exception.name` and
137
+ `exception.message`, and the logger's name rides in `attrs.logger`. Records ship as
138
+ `source="logging"`. structlog and loguru both bridge to stdlib logging; wire the handler
139
+ there.
140
+
141
+ ### WSGI and ASGI
142
+
143
+ ```python
144
+ # Flask
145
+ app.wsgi_app = agent.wsgi_middleware(app.wsgi_app)
146
+ # Django: wrap get_wsgi_application() in wsgi.py
147
+ application = agent.wsgi_middleware(get_wsgi_application())
148
+ # Starlette / FastAPI, or any ASGI app
149
+ app = agent.asgi_middleware(app, route=route_template)
150
+ ```
151
+
152
+ `route=` is called when the response starts, after routing. For Starlette:
153
+
154
+ ```python
155
+ def route_template(scope):
156
+ endpoint = scope.get("endpoint")
157
+ for r in getattr(scope.get("router"), "routes", []):
158
+ if getattr(r, "endpoint", None) is endpoint:
159
+ return r.path # "/orders/{order_id}"
160
+ return None
161
+ ```
162
+
163
+ A request with status ≥ `min_status` (default 500) records a `source=http` line with
164
+ `http.method`, `http.path`, `http.status` and `http.duration_ms`. An exception out of
165
+ the app is recorded and re-raised. The request context is ambient (`contextvars`), so
166
+ an error logged three calls down carries the path that produced it. Pass `route=` to
167
+ map a request to a low-cardinality template; the default is the raw path without the
168
+ query string.
169
+
170
+ ```python
171
+ with agent.context({"reqId": rid}):
172
+ ... # every line inside carries reqId
173
+ ```
174
+
175
+ Threads you spawn do not inherit the context; run them with `contextvars.copy_context().run`.
176
+
177
+ ### AWS Lambda
178
+
179
+ ```python
180
+ handler = agent.wrap_handler(handler)
181
+ ```
182
+
183
+ On a detected serverless platform (Lambda, Vercel, GCP Functions, Azure Functions) no
184
+ background thread is started. The buffer is drained at handler end, inside a budget
185
+ derived from `context.get_remaining_time_in_millis()`, and the heartbeat rides along.
186
+
187
+ ## What it does to your process on a crash
188
+
189
+ - **Uncaught exception on the main thread**: `sys.excepthook` is chained. The line is
190
+ captured, a synchronous flush runs for at most `fatal_flush_timeout_ms` (default 2 s),
191
+ then the previous hook runs. Python prints its own traceback and picks its own exit
192
+ code; nothing is reproduced or suppressed.
193
+ - **Uncaught exception in a thread**: `threading.excepthook` is chained. Captured, no
194
+ flush; the process survives exactly as it would have.
195
+ - **Exit**: an `atexit` handler records `process exiting` and flushes with the same
196
+ deadline. `process.exit_code` is `null` unless an uncaught exception was observed
197
+ (then `1`): `atexit` cannot see the status passed to `sys.exit()`.
198
+ - **Not captured**: `os._exit`, `SIGKILL`/OOM, signals whose default action terminates
199
+ (add your own `SIGTERM` handler and call `agent.close()`), `KeyboardInterrupt` (passed
200
+ straight through, not recorded), and an app that replaces `sys.excepthook` after init.
201
+
202
+ `capture_exceptions=False` and `capture_process_events=False` turn these off.
203
+
204
+ ## Configuration
205
+
206
+ Every option has an environment variable. Explicit kwargs win over the environment.
207
+
208
+ | option | env | default |
209
+ |---|---|---|
210
+ | `service` | `UPTIMEAGENT_SERVICE` | required |
211
+ | `serves` / `site` | `UPTIMEAGENT_SERVES` (comma-separated) / `UPTIMEAGENT_SITE` | absent |
212
+ | `token` | `UPTIMEAGENT_TOKEN` (`UA_TOKEN` deprecated) | none: installed and inert |
213
+ | `environment` | `UPTIMEAGENT_ENVIRONMENT` | `production` (a display label; never affects routing) |
214
+ | `instance` | `UPTIMEAGENT_INSTANCE` | platform id → hostname → generated |
215
+ | `endpoint` | `UPTIMEAGENT_ENDPOINT` | `https://ingest.uptimeagent.com` |
216
+ | `mode` | `UPTIMEAGENT_MODE`; `UPTIMEAGENT_DISABLED=1` forces `off` | `send` |
217
+ | `min_level` | `UPTIMEAGENT_MIN_LEVEL` | `warn` |
218
+ | `redact` | | `{}` |
219
+ | `capture_exceptions` | `UPTIMEAGENT_CAPTURE_EXCEPTIONS` | `True` |
220
+ | `capture_logging` | `UPTIMEAGENT_CAPTURE_LOGGING` | `False` |
221
+ | `capture_process_events` | `UPTIMEAGENT_CAPTURE_PROCESS` | `True` |
222
+ | `flush_interval_ms` | `UPTIMEAGENT_FLUSH_INTERVAL_MS` | 2000 |
223
+ | `heartbeat_interval_ms` | `UPTIMEAGENT_HEARTBEAT_INTERVAL_MS` | 30000 |
224
+ | `request_timeout_ms` | `UPTIMEAGENT_REQUEST_TIMEOUT_MS` | 10000 |
225
+ | `fatal_flush_timeout_ms` | `UPTIMEAGENT_FATAL_FLUSH_TIMEOUT_MS` | 2000 |
226
+ | `max_buffer_lines` / `max_buffer_bytes` | `UPTIMEAGENT_MAX_BUFFER_LINES` / `_BYTES` | 10000 / 8 MiB |
227
+ | `max_batch_lines` / `max_batch_bytes` | `UPTIMEAGENT_MAX_BATCH_LINES` / `_BYTES` | 500 / 256 KiB |
228
+ | `max_lines_per_second` / `max_bytes_per_second` | `UPTIMEAGENT_MAX_LINES_PER_SECOND` / `_MAX_BYTES_PER_SECOND` | 500 / 1 MiB |
229
+ | `serverless` | `UPTIMEAGENT_SERVERLESS` | `auto` |
230
+ | `debug` | `UPTIMEAGENT_DEBUG` | `False` |
231
+ | `on_error(err, ctx)` | | none |
232
+ | `on_config_error` | | `throw` (or `disable`) |
233
+
234
+ `uptime_agent()` raises `ConfigError` only for malformed configuration: a missing
235
+ `service`, an unrecognised `mode`, or a non-HTTPS endpoint (allowed only for
236
+ `localhost`, `127.0.0.1`, `::1`). It happens at startup, before any socket opens. Pass
237
+ `on_config_error="disable"` for a warning and an inert agent instead. A missing token
238
+ never raises: the agent warns once and stays inert.
239
+
240
+ ### `mode="log"`: the development path
241
+
242
+ Renders each batch to stdout as NDJSON and never opens a socket. Needs no backend at all.
243
+ It is explicit only, never inferred from `environment`.
244
+
245
+ ### Endpoint and environment
246
+
247
+ The endpoint is never derived from `environment`. Your staging belongs on the
248
+ production backend, labelled `environment="staging"`. Proxies: `HTTPS_PROXY`,
249
+ `HTTP_PROXY` and `NO_PROXY` are honoured (CONNECT tunnelling via `urllib`); macOS system
250
+ proxy settings are not consulted. HTTP redirects are not followed.
251
+
252
+ ## Failure mode
253
+
254
+ Everything the SDK cannot do is counted, never raised:
255
+
256
+ ```python
257
+ >>> agent.stats().as_dict()
258
+ {'service': 'checkout-api', 'instance': 'ip-10-2-3-41', ..., 'enabled': True,
259
+ 'disabled_reason': None, 'buffered': {'lines': 0, 'bytes': 0},
260
+ 'sent': {'lines': 1204, 'batches': 17},
261
+ 'dropped': {'buffer': 0, 'throttle': 0, 'oversize': 0, 'transport': 2, 'internal': 0, 'filtered': 184}}
262
+ ```
263
+
264
+ `filtered` is lines below `min_level`. A `401`/`403` from ingest disables the transport
265
+ for the life of the process and calls `on_error` once; after that every line counts as
266
+ `dropped.transport`. `tests/test_never_hurt_the_app.py` is the suite that holds these
267
+ claims up.
268
+
269
+ ## Verification
270
+
271
+ | command | what it proves |
272
+ |---|---|
273
+ | `make test` | ~260 tests, including the 89 protocol conformance fixtures and subprocess crash scenarios |
274
+ | `make test-matrix` | the same on CPython 3.9 through 3.13 |
275
+ | `make integration` | the built wheel under real Flask, Starlette/uvicorn and gunicorn prefork, against a local ingest stub, with a byte scan for planted secrets |
276
+ | `make verify` | lint, types, tests, package checks: what `deploy.sh` gates on |
277
+
278
+ ## Where it runs
279
+
280
+ CPython 3.9 to 3.13. PyPy is best-effort. gunicorn and uwsgi prefork are handled with
281
+ `os.register_at_fork`: a forked worker starts its own sender thread lazily and gets a
282
+ `#<pid>` instance suffix. asyncio apps use the ASGI middleware; `flush()` and `close()`
283
+ are synchronous and should not be awaited on an event loop (`await asyncio.to_thread(agent.flush)`).
284
+
285
+ Differences from the Node SDK: `docs/differences-from-node.md`. Publishing:
286
+ `docs/publishing.md`. Internal staging notes: `docs/staging.md`.
287
+
288
+ MIT. Zero dependencies. Python 3.9+.
@@ -0,0 +1,258 @@
1
+ # uptime-agent
2
+
3
+ Ship the errors your Python app already logs to UptimeAgent, so an incident arrives
4
+ with your own error lines attached to the outside-in probe evidence.
5
+
6
+ **Status: 0.x.** Minor versions may break until 1.0.0. The wire protocol is frozen and
7
+ executable (`docs/wire-protocol-v1.md`, `conformance/`), and this package passes every
8
+ fixture in it.
9
+
10
+ ```bash
11
+ pip install uptime-agent
12
+ ```
13
+
14
+ The distribution is `uptime-agent`; the import is `uptimeagent`.
15
+
16
+ ```python
17
+ import uptimeagent
18
+
19
+ agent = uptimeagent.uptime_agent(
20
+ service="checkout-api", # required: how the Agents screen groups instances
21
+ serves=["checkout.acme.com"], # the hostnames exactly as they appear in Monitors
22
+ )
23
+ # token comes from UPTIMEAGENT_TOKEN
24
+ ```
25
+
26
+ ## The boundary: evidence, never detection
27
+
28
+ A quiet package is **never** read as downtime, and a package reporting healthy is
29
+ **never** read as uptime. A process that has stopped answering cannot vote on its own
30
+ health, so detection belongs entirely to UptimeAgent's external probe regions. Nothing
31
+ this package sends, or fails to send, can open, close or influence an incident.
32
+
33
+ ## What it never does
34
+
35
+ - **Never raises into your code** after `uptime_agent()` returns. Every public method
36
+ and every hook is wrapped; a failure drops the event and bumps a counter you can read
37
+ with `agent.stats()`.
38
+ - **Never blocks your request path.** `emit` is a redaction walk with a hard budget and
39
+ a buffer append. Network I/O happens on one daemon thread that holds nothing open.
40
+ - **Never grows without bound.** The buffer is hard-capped (lines and bytes), drop-oldest,
41
+ with the in-flight batch still counted against the cap.
42
+ - **Never opens a listening socket, never installs a signal handler, never patches
43
+ `print` or `sys.stdout`.** There is no inbound control path.
44
+ - **Never reads an environment variable outside the documented list**: the
45
+ `UPTIMEAGENT_*` options, the deprecated `UA_TOKEN`, and `uptimeagent.PLATFORM_ENV_VARS`
46
+ (platform detection, proxies, and the three variables the standard library's TLS
47
+ setup reads: `SSL_CERT_FILE`, `SSL_CERT_DIR`, `SSLKEYLOGFILE`). A test wraps
48
+ `os.environ` and fails on any read outside that list.
49
+ - **Zero runtime dependencies.** `pip show uptimeagent` prints an empty `Requires:` line.
50
+ The sdist on PyPI is the exact source that runs in your process.
51
+
52
+ ## What it collects
53
+
54
+ Only what you point it at. Lines at or above `min_level` (default `warn`) from the
55
+ hooks you attach, plus three synthesised kinds: an uncaught exception (`source=exception`),
56
+ a failing HTTP request (`source=http`: method, route, status, duration), and process
57
+ start and exit (`source=process`). Every line is redacted in-process before it is
58
+ buffered.
59
+
60
+ ## Redaction
61
+
62
+ Runs before anything leaves the process, with a fixed node, depth and byte budget so a
63
+ large or cyclic object cannot slow a request. Two categories, two markers:
64
+
65
+ - **Keys**: `[redacted:keys]`. A key is split into words and every contiguous run of
66
+ words is matched against the rules, over the whole flattened path, so `body.card.number`
67
+ matches `cardnumber`. Defaults: `password passwd pwd secret token apikey accesskey
68
+ authorization auth cookie session credential privatekey clientsecret otp cvv cardnumber ssn`.
69
+ - **Patterns**: `[redacted:patterns]` over the message and every string value, in a
70
+ fixed order: bearer tokens, URL credentials, JWTs, AWS access key ids, private key
71
+ blocks, email addresses.
72
+
73
+ Your rules extend the defaults; they never replace them.
74
+
75
+ ```python
76
+ import re
77
+
78
+ agent = uptimeagent.uptime_agent(
79
+ service="checkout-api",
80
+ redact={
81
+ "keys": ["customer_ref"],
82
+ "patterns": [
83
+ re.compile(r"\bACC-\d{8}\b"),
84
+ {"name": "iban", "regex": r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b", "flags": ""},
85
+ ],
86
+ },
87
+ )
88
+ ```
89
+
90
+ Default patterns use ASCII `\b` and `\s` (`re.ASCII`), matching the ingest backstop.
91
+ Your own compiled patterns are used exactly as you compiled them.
92
+
93
+ ## Wiring
94
+
95
+ ### stdlib `logging`
96
+
97
+ ```python
98
+ import logging
99
+
100
+ logging.getLogger().addHandler(agent.logging_handler())
101
+ ```
102
+
103
+ Or `capture_logging=True` (root logger, level from `min_level`) or
104
+ `capture_logging={"level": "WARNING", "logger": "myapp"}` at init. Levels map by name
105
+ (`CRITICAL`/`ERROR` → `error`, `WARNING` → `warn`, everything else → `info`), `extra=`
106
+ fields become attrs, `exc_info` becomes the traceback plus `exception.name` and
107
+ `exception.message`, and the logger's name rides in `attrs.logger`. Records ship as
108
+ `source="logging"`. structlog and loguru both bridge to stdlib logging; wire the handler
109
+ there.
110
+
111
+ ### WSGI and ASGI
112
+
113
+ ```python
114
+ # Flask
115
+ app.wsgi_app = agent.wsgi_middleware(app.wsgi_app)
116
+ # Django: wrap get_wsgi_application() in wsgi.py
117
+ application = agent.wsgi_middleware(get_wsgi_application())
118
+ # Starlette / FastAPI, or any ASGI app
119
+ app = agent.asgi_middleware(app, route=route_template)
120
+ ```
121
+
122
+ `route=` is called when the response starts, after routing. For Starlette:
123
+
124
+ ```python
125
+ def route_template(scope):
126
+ endpoint = scope.get("endpoint")
127
+ for r in getattr(scope.get("router"), "routes", []):
128
+ if getattr(r, "endpoint", None) is endpoint:
129
+ return r.path # "/orders/{order_id}"
130
+ return None
131
+ ```
132
+
133
+ A request with status ≥ `min_status` (default 500) records a `source=http` line with
134
+ `http.method`, `http.path`, `http.status` and `http.duration_ms`. An exception out of
135
+ the app is recorded and re-raised. The request context is ambient (`contextvars`), so
136
+ an error logged three calls down carries the path that produced it. Pass `route=` to
137
+ map a request to a low-cardinality template; the default is the raw path without the
138
+ query string.
139
+
140
+ ```python
141
+ with agent.context({"reqId": rid}):
142
+ ... # every line inside carries reqId
143
+ ```
144
+
145
+ Threads you spawn do not inherit the context; run them with `contextvars.copy_context().run`.
146
+
147
+ ### AWS Lambda
148
+
149
+ ```python
150
+ handler = agent.wrap_handler(handler)
151
+ ```
152
+
153
+ On a detected serverless platform (Lambda, Vercel, GCP Functions, Azure Functions) no
154
+ background thread is started. The buffer is drained at handler end, inside a budget
155
+ derived from `context.get_remaining_time_in_millis()`, and the heartbeat rides along.
156
+
157
+ ## What it does to your process on a crash
158
+
159
+ - **Uncaught exception on the main thread**: `sys.excepthook` is chained. The line is
160
+ captured, a synchronous flush runs for at most `fatal_flush_timeout_ms` (default 2 s),
161
+ then the previous hook runs. Python prints its own traceback and picks its own exit
162
+ code; nothing is reproduced or suppressed.
163
+ - **Uncaught exception in a thread**: `threading.excepthook` is chained. Captured, no
164
+ flush; the process survives exactly as it would have.
165
+ - **Exit**: an `atexit` handler records `process exiting` and flushes with the same
166
+ deadline. `process.exit_code` is `null` unless an uncaught exception was observed
167
+ (then `1`): `atexit` cannot see the status passed to `sys.exit()`.
168
+ - **Not captured**: `os._exit`, `SIGKILL`/OOM, signals whose default action terminates
169
+ (add your own `SIGTERM` handler and call `agent.close()`), `KeyboardInterrupt` (passed
170
+ straight through, not recorded), and an app that replaces `sys.excepthook` after init.
171
+
172
+ `capture_exceptions=False` and `capture_process_events=False` turn these off.
173
+
174
+ ## Configuration
175
+
176
+ Every option has an environment variable. Explicit kwargs win over the environment.
177
+
178
+ | option | env | default |
179
+ |---|---|---|
180
+ | `service` | `UPTIMEAGENT_SERVICE` | required |
181
+ | `serves` / `site` | `UPTIMEAGENT_SERVES` (comma-separated) / `UPTIMEAGENT_SITE` | absent |
182
+ | `token` | `UPTIMEAGENT_TOKEN` (`UA_TOKEN` deprecated) | none: installed and inert |
183
+ | `environment` | `UPTIMEAGENT_ENVIRONMENT` | `production` (a display label; never affects routing) |
184
+ | `instance` | `UPTIMEAGENT_INSTANCE` | platform id → hostname → generated |
185
+ | `endpoint` | `UPTIMEAGENT_ENDPOINT` | `https://ingest.uptimeagent.com` |
186
+ | `mode` | `UPTIMEAGENT_MODE`; `UPTIMEAGENT_DISABLED=1` forces `off` | `send` |
187
+ | `min_level` | `UPTIMEAGENT_MIN_LEVEL` | `warn` |
188
+ | `redact` | | `{}` |
189
+ | `capture_exceptions` | `UPTIMEAGENT_CAPTURE_EXCEPTIONS` | `True` |
190
+ | `capture_logging` | `UPTIMEAGENT_CAPTURE_LOGGING` | `False` |
191
+ | `capture_process_events` | `UPTIMEAGENT_CAPTURE_PROCESS` | `True` |
192
+ | `flush_interval_ms` | `UPTIMEAGENT_FLUSH_INTERVAL_MS` | 2000 |
193
+ | `heartbeat_interval_ms` | `UPTIMEAGENT_HEARTBEAT_INTERVAL_MS` | 30000 |
194
+ | `request_timeout_ms` | `UPTIMEAGENT_REQUEST_TIMEOUT_MS` | 10000 |
195
+ | `fatal_flush_timeout_ms` | `UPTIMEAGENT_FATAL_FLUSH_TIMEOUT_MS` | 2000 |
196
+ | `max_buffer_lines` / `max_buffer_bytes` | `UPTIMEAGENT_MAX_BUFFER_LINES` / `_BYTES` | 10000 / 8 MiB |
197
+ | `max_batch_lines` / `max_batch_bytes` | `UPTIMEAGENT_MAX_BATCH_LINES` / `_BYTES` | 500 / 256 KiB |
198
+ | `max_lines_per_second` / `max_bytes_per_second` | `UPTIMEAGENT_MAX_LINES_PER_SECOND` / `_MAX_BYTES_PER_SECOND` | 500 / 1 MiB |
199
+ | `serverless` | `UPTIMEAGENT_SERVERLESS` | `auto` |
200
+ | `debug` | `UPTIMEAGENT_DEBUG` | `False` |
201
+ | `on_error(err, ctx)` | | none |
202
+ | `on_config_error` | | `throw` (or `disable`) |
203
+
204
+ `uptime_agent()` raises `ConfigError` only for malformed configuration: a missing
205
+ `service`, an unrecognised `mode`, or a non-HTTPS endpoint (allowed only for
206
+ `localhost`, `127.0.0.1`, `::1`). It happens at startup, before any socket opens. Pass
207
+ `on_config_error="disable"` for a warning and an inert agent instead. A missing token
208
+ never raises: the agent warns once and stays inert.
209
+
210
+ ### `mode="log"`: the development path
211
+
212
+ Renders each batch to stdout as NDJSON and never opens a socket. Needs no backend at all.
213
+ It is explicit only, never inferred from `environment`.
214
+
215
+ ### Endpoint and environment
216
+
217
+ The endpoint is never derived from `environment`. Your staging belongs on the
218
+ production backend, labelled `environment="staging"`. Proxies: `HTTPS_PROXY`,
219
+ `HTTP_PROXY` and `NO_PROXY` are honoured (CONNECT tunnelling via `urllib`); macOS system
220
+ proxy settings are not consulted. HTTP redirects are not followed.
221
+
222
+ ## Failure mode
223
+
224
+ Everything the SDK cannot do is counted, never raised:
225
+
226
+ ```python
227
+ >>> agent.stats().as_dict()
228
+ {'service': 'checkout-api', 'instance': 'ip-10-2-3-41', ..., 'enabled': True,
229
+ 'disabled_reason': None, 'buffered': {'lines': 0, 'bytes': 0},
230
+ 'sent': {'lines': 1204, 'batches': 17},
231
+ 'dropped': {'buffer': 0, 'throttle': 0, 'oversize': 0, 'transport': 2, 'internal': 0, 'filtered': 184}}
232
+ ```
233
+
234
+ `filtered` is lines below `min_level`. A `401`/`403` from ingest disables the transport
235
+ for the life of the process and calls `on_error` once; after that every line counts as
236
+ `dropped.transport`. `tests/test_never_hurt_the_app.py` is the suite that holds these
237
+ claims up.
238
+
239
+ ## Verification
240
+
241
+ | command | what it proves |
242
+ |---|---|
243
+ | `make test` | ~260 tests, including the 89 protocol conformance fixtures and subprocess crash scenarios |
244
+ | `make test-matrix` | the same on CPython 3.9 through 3.13 |
245
+ | `make integration` | the built wheel under real Flask, Starlette/uvicorn and gunicorn prefork, against a local ingest stub, with a byte scan for planted secrets |
246
+ | `make verify` | lint, types, tests, package checks: what `deploy.sh` gates on |
247
+
248
+ ## Where it runs
249
+
250
+ CPython 3.9 to 3.13. PyPy is best-effort. gunicorn and uwsgi prefork are handled with
251
+ `os.register_at_fork`: a forked worker starts its own sender thread lazily and gets a
252
+ `#<pid>` instance suffix. asyncio apps use the ASGI middleware; `flush()` and `close()`
253
+ are synchronous and should not be awaited on an event loop (`await asyncio.to_thread(agent.flush)`).
254
+
255
+ Differences from the Node SDK: `docs/differences-from-node.md`. Publishing:
256
+ `docs/publishing.md`. Internal staging notes: `docs/staging.md`.
257
+
258
+ MIT. Zero dependencies. Python 3.9+.