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.
- uptime_agent-0.1.0/.gitignore +12 -0
- uptime_agent-0.1.0/CHANGELOG.md +28 -0
- uptime_agent-0.1.0/LICENSE +21 -0
- uptime_agent-0.1.0/PKG-INFO +288 -0
- uptime_agent-0.1.0/README.md +258 -0
- uptime_agent-0.1.0/docs/wire-protocol-v1.md +456 -0
- uptime_agent-0.1.0/pyproject.toml +129 -0
- uptime_agent-0.1.0/src/uptimeagent/__init__.py +302 -0
- uptime_agent-0.1.0/src/uptimeagent/_bytes.py +50 -0
- uptime_agent-0.1.0/src/uptimeagent/_json.py +110 -0
- uptime_agent-0.1.0/src/uptimeagent/_version.py +8 -0
- uptime_agent-0.1.0/src/uptimeagent/agent.py +772 -0
- uptime_agent-0.1.0/src/uptimeagent/backoff.py +87 -0
- uptime_agent-0.1.0/src/uptimeagent/buffer.py +89 -0
- uptime_agent-0.1.0/src/uptimeagent/config.py +337 -0
- uptime_agent-0.1.0/src/uptimeagent/hooks/__init__.py +0 -0
- uptime_agent-0.1.0/src/uptimeagent/hooks/asgi.py +127 -0
- uptime_agent-0.1.0/src/uptimeagent/hooks/context.py +47 -0
- uptime_agent-0.1.0/src/uptimeagent/hooks/exceptions.py +156 -0
- uptime_agent-0.1.0/src/uptimeagent/hooks/logging_handler.py +117 -0
- uptime_agent-0.1.0/src/uptimeagent/hooks/wsgi.py +160 -0
- uptime_agent-0.1.0/src/uptimeagent/instance.py +180 -0
- uptime_agent-0.1.0/src/uptimeagent/levels.py +38 -0
- uptime_agent-0.1.0/src/uptimeagent/line.py +165 -0
- uptime_agent-0.1.0/src/uptimeagent/protocol.py +203 -0
- uptime_agent-0.1.0/src/uptimeagent/py.typed +0 -0
- uptime_agent-0.1.0/src/uptimeagent/ratelimit.py +51 -0
- uptime_agent-0.1.0/src/uptimeagent/redact.py +348 -0
- uptime_agent-0.1.0/src/uptimeagent/runtime.py +63 -0
- uptime_agent-0.1.0/src/uptimeagent/safe.py +76 -0
- uptime_agent-0.1.0/src/uptimeagent/serverless.py +49 -0
- uptime_agent-0.1.0/src/uptimeagent/transport.py +184 -0
|
@@ -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+.
|