uptime-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,29 @@
1
+ uptimeagent/__init__.py,sha256=fKnPNGQdO8707Dz30jNpGMJngiaDdcqptRGnIsQ3K78,10418
2
+ uptimeagent/_bytes.py,sha256=UeRhIdQdXc01VsLSGYFSQFMT8ddfC6w3SlxJ4nRtErY,1626
3
+ uptimeagent/_json.py,sha256=rzkdVVpoScaJqIHu8RZKHT7fbNGz3Rv46FgwM0Ze5mM,3286
4
+ uptimeagent/_version.py,sha256=01VPrjjB-7V2AubGKa6GCpy776SB05kd5_fMXYvo1Ng,280
5
+ uptimeagent/agent.py,sha256=0b3Q_V6liUrtundRZUDlH9Zyaydv-rWzyMTZ5zZRhKY,29385
6
+ uptimeagent/backoff.py,sha256=hiA1X0eSMomEJPruudX_vVfYHvq7h4RlnB5vNY66xk8,2429
7
+ uptimeagent/buffer.py,sha256=371CRJArTqT9c2ZDObPgaHtVMN70HRXqyvXmto76TVQ,3126
8
+ uptimeagent/config.py,sha256=_BMe3WlsPkpobKQieMPYBo8a56M6RWlrLKtPniT4ojA,11380
9
+ uptimeagent/instance.py,sha256=A2QEzf_gZ0df9uyQhSPMJcL9CNczeUL5onYd5LQut4U,5273
10
+ uptimeagent/levels.py,sha256=D66g-SMZQjPWofaia9Bq0qIXxa9flBsOHnMmx-yqy_U,1136
11
+ uptimeagent/line.py,sha256=-7Slm6GwwDkCIHb12pYNKd57qHRQiFuzyyhiqCOmPRQ,5439
12
+ uptimeagent/protocol.py,sha256=wo9wd0n9YMD9epeeQ4lyuurZCEgo2azVw3Kbv6MaXB8,6160
13
+ uptimeagent/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ uptimeagent/ratelimit.py,sha256=t8-Zw3oiXMlKZ9g4eCzKhOjJFXDwXFTOjKOtswBUdl4,1524
15
+ uptimeagent/redact.py,sha256=9P4MKliuyF1cnqRHMosTgO5ok2K6n9oS1a76RJl0asw,11458
16
+ uptimeagent/runtime.py,sha256=V0XNzi6T-ye7t7PQGXPSezcvDBNx206yKjfXYqLElVE,1684
17
+ uptimeagent/safe.py,sha256=uIq61S0MehSZmiXuassy3hVbtPV0AvBrqzrh4HdOGhI,2294
18
+ uptimeagent/serverless.py,sha256=QTE1G1oIkmSl-sDEqqw_GGxgn7q3dEqIS9tp9kzh7Wc,1687
19
+ uptimeagent/transport.py,sha256=kAaKhjkxRrYPTVw9C-qGzFUWCwJd4AJfohVl06XGZoA,6763
20
+ uptimeagent/hooks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
+ uptimeagent/hooks/asgi.py,sha256=a77wnNNqCFQyac3j881ZAtwPO2NbVGjNLmjSqyp7OaQ,4547
22
+ uptimeagent/hooks/context.py,sha256=Ae_tTXE_W_vsT9PKu2YxBZsijiAUHooFD41iw2s0kjY,1357
23
+ uptimeagent/hooks/exceptions.py,sha256=y1czwx_363oQXYsDUby53kgC3XUTyRf1sLjL2zI7c38,5334
24
+ uptimeagent/hooks/logging_handler.py,sha256=gdYyGWjGsxGfGYvL0q3KNSMqog3av7UtllQlrMwtp1w,3891
25
+ uptimeagent/hooks/wsgi.py,sha256=BqXFsvRyE40tTQY9x_DR28QkNs4HUNA5FgdgCPXzRwI,5201
26
+ uptime_agent-0.1.0.dist-info/METADATA,sha256=ExlPrKyI3z-GkkDammvoUUb_JDA28BvmZkyVDhR08uc,13221
27
+ uptime_agent-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
28
+ uptime_agent-0.1.0.dist-info/licenses/LICENSE,sha256=PhLFceKziIxkjbxaGiqOyKmdocuP2lIaCjNShmx0dZs,1068
29
+ uptime_agent-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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,302 @@
1
+ """uptimeagent: the public entry point.
2
+
3
+ THE BOUNDARY, first, because it is a product decision and not a detail: this package
4
+ is EVIDENCE, never detection. A quiet package is never read as downtime, and a package
5
+ reporting healthy is never read as uptime. Nothing this package sends, or fails to
6
+ send, can open, close or influence an incident.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import logging
13
+ from collections.abc import Iterator, Mapping
14
+ from contextlib import contextmanager
15
+ from typing import Any, Callable, TypeVar, cast
16
+
17
+ from ._version import __version__
18
+ from .agent import Agent, AgentStats, Counter
19
+ from .config import ConfigError, resolve_config
20
+ from .hooks.asgi import AsgiMiddleware
21
+ from .hooks.context import context as _context
22
+ from .hooks.context import current_context, run_with_context
23
+ from .hooks.exceptions import install_exception_capture, install_process_events
24
+ from .hooks.logging_handler import UptimeAgentHandler
25
+ from .hooks.wsgi import WsgiMiddleware
26
+ from .instance import PLATFORM_ENV_VARS, default_hostname
27
+ from .protocol import LEVELS, MARKERS, PROTOCOL_VERSION, SOURCES
28
+ from .runtime import write_err
29
+ from .safe import ErrorContext
30
+ from .serverless import wrap_handler as _wrap_handler
31
+ from .transport import HttpTransport, UrllibTransport
32
+
33
+ __all__ = [
34
+ "LEVELS",
35
+ "MARKERS",
36
+ "PLATFORM_ENV_VARS",
37
+ "PROTOCOL_VERSION",
38
+ "SOURCES",
39
+ "AgentStats",
40
+ "ConfigError",
41
+ "ErrorContext",
42
+ "HttpTransport",
43
+ "UptimeAgent",
44
+ "UptimeAgentHandler",
45
+ "__version__",
46
+ "current_context",
47
+ "get_agent",
48
+ "run_with_context",
49
+ "uptime_agent",
50
+ ]
51
+
52
+ F = TypeVar("F", bound=Callable[..., Any])
53
+
54
+
55
+ def _guarded(fallback: Any = None, stage: str = "hook") -> Callable[[F], F]:
56
+ """Every public entry point is wrapped, so even a bug in this file cannot reach the
57
+ customer's stack. Only `Exception`: KeyboardInterrupt/SystemExit propagate."""
58
+
59
+ def deco(fn: F) -> F:
60
+ @functools.wraps(fn)
61
+ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
62
+ try:
63
+ return fn(self, *args, **kwargs)
64
+ except Exception as e:
65
+ try:
66
+ self._agent.report_internal(e, stage)
67
+ except Exception:
68
+ pass
69
+ return fallback
70
+
71
+ return wrapper # type: ignore[return-value]
72
+
73
+ return deco
74
+
75
+
76
+ class UptimeAgent:
77
+ """The handle `uptime_agent()` returns. Every method is safe to call at any time."""
78
+
79
+ def __init__(self, agent: Agent) -> None:
80
+ self._agent = agent
81
+
82
+ @property
83
+ def enabled(self) -> bool:
84
+ return self._agent.enabled
85
+
86
+ @_guarded()
87
+ def log(
88
+ self, level: str, message: Any, attrs: Mapping[str, Any] | None = None, **extra: Any
89
+ ) -> None:
90
+ self._agent.emit(level=level, source="sdk", message=message, attrs=_merge(attrs, extra))
91
+
92
+ @_guarded()
93
+ def info(self, message: Any, attrs: Mapping[str, Any] | None = None, **extra: Any) -> None:
94
+ self._agent.emit(level="info", source="sdk", message=message, attrs=_merge(attrs, extra))
95
+
96
+ @_guarded()
97
+ def warn(self, message: Any, attrs: Mapping[str, Any] | None = None, **extra: Any) -> None:
98
+ self._agent.emit(level="warn", source="sdk", message=message, attrs=_merge(attrs, extra))
99
+
100
+ @_guarded()
101
+ def error(self, message: Any, attrs: Mapping[str, Any] | None = None, **extra: Any) -> None:
102
+ self._agent.emit(level="error", source="sdk", message=message, attrs=_merge(attrs, extra))
103
+
104
+ @_guarded()
105
+ def capture_exception(
106
+ self, exc: BaseException, attrs: Mapping[str, Any] | None = None, **extra: Any
107
+ ) -> None:
108
+ merged = dict(_merge(attrs, extra) or {})
109
+ merged.setdefault("exception.origin", "capture_exception")
110
+ merged.setdefault("exception.name", type(exc).__name__)
111
+ try:
112
+ merged.setdefault("exception.message", str(exc))
113
+ except Exception:
114
+ pass
115
+ self._agent.emit(level="error", source="exception", message=exc, attrs=merged)
116
+
117
+ @_guarded(stage="flush")
118
+ def flush(self, timeout_ms: int | None = None) -> None:
119
+ """Drain what is buffered. Synchronous, bounded; never sits through a backoff."""
120
+ self._agent.flush(timeout_ms=timeout_ms)
121
+
122
+ @_guarded(stage="shutdown")
123
+ def close(self) -> None:
124
+ """Detach every hook and flush once. Safe to call more than once."""
125
+ self._agent.close()
126
+
127
+ def stats(self) -> AgentStats:
128
+ return self._agent.stats()
129
+
130
+ @_guarded(fallback=logging.NullHandler())
131
+ def logging_handler(self, level: int | None = None) -> logging.Handler:
132
+ """`logging.getLogger().addHandler(agent.logging_handler())`"""
133
+ return UptimeAgentHandler(self._agent, level)
134
+
135
+ def wsgi_middleware(
136
+ self, app: Any, min_status: int = 500, route: Callable[[Any], str | None] | None = None
137
+ ) -> Any:
138
+ try:
139
+ return WsgiMiddleware(app, self._agent, min_status=min_status, route=route)
140
+ except Exception as e:
141
+ self._agent.report_internal(e, "hook")
142
+ return app
143
+
144
+ def asgi_middleware(
145
+ self, app: Any, min_status: int = 500, route: Callable[[Any], str | None] | None = None
146
+ ) -> Any:
147
+ try:
148
+ return AsgiMiddleware(app, self._agent, min_status=min_status, route=route)
149
+ except Exception as e:
150
+ self._agent.report_internal(e, "hook")
151
+ return app
152
+
153
+ def run_with_context(
154
+ self, attrs: Mapping[str, Any], fn: Callable[..., Any], *args: Any, **kwargs: Any
155
+ ) -> Any:
156
+ return run_with_context(attrs, fn, *args, **kwargs)
157
+
158
+ @contextmanager
159
+ def context(self, attrs: Mapping[str, Any]) -> Iterator[None]:
160
+ """`with agent.context({"reqId": rid}): ...` attaches attrs to every line inside."""
161
+ with _context(attrs):
162
+ yield
163
+
164
+ def wrap_handler(self, handler: F) -> F:
165
+ """AWS Lambda: `handler = agent.wrap_handler(handler)`."""
166
+ try:
167
+ return cast(F, _wrap_handler(self._agent, handler))
168
+ except Exception as e:
169
+ self._agent.report_internal(e, "hook")
170
+ return handler
171
+
172
+
173
+ class _InertAgent(UptimeAgent):
174
+ """What a disabled agent looks like: every hook present, every hook inert."""
175
+
176
+ def __init__(self) -> None:
177
+ self._agent = None # type: ignore[assignment]
178
+
179
+ @property
180
+ def enabled(self) -> bool:
181
+ return False
182
+
183
+ def log(self, *a: Any, **k: Any) -> None:
184
+ return None
185
+
186
+ info = warn = error = capture_exception = log
187
+
188
+ def flush(self, timeout_ms: int | None = None) -> None:
189
+ return None
190
+
191
+ def close(self) -> None:
192
+ return None
193
+
194
+ def stats(self) -> AgentStats:
195
+ return AgentStats(
196
+ service="",
197
+ instance="",
198
+ environment="",
199
+ serves=None,
200
+ runtime="",
201
+ platform="unknown",
202
+ serverless=False,
203
+ mode="off",
204
+ enabled=False,
205
+ disabled_reason="invalid configuration",
206
+ buffered=Counter(lines=0, bytes=0),
207
+ sent=Counter(lines=0, batches=0),
208
+ dropped=Counter(buffer=0, throttle=0, oversize=0, transport=0, internal=0, filtered=0),
209
+ )
210
+
211
+ def logging_handler(self, level: int | None = None) -> logging.Handler:
212
+ return logging.NullHandler()
213
+
214
+ def wsgi_middleware(self, app: Any, min_status: int = 500, route: Any = None) -> Any:
215
+ return app
216
+
217
+ def asgi_middleware(self, app: Any, min_status: int = 500, route: Any = None) -> Any:
218
+ return app
219
+
220
+ def wrap_handler(self, handler: F) -> F:
221
+ return handler
222
+
223
+
224
+ def _merge(attrs: Mapping[str, Any] | None, extra: Mapping[str, Any]) -> Mapping[str, Any] | None:
225
+ if not extra:
226
+ return attrs
227
+ merged = dict(attrs) if isinstance(attrs, Mapping) else {}
228
+ merged.update(extra)
229
+ return merged
230
+
231
+
232
+ _current: UptimeAgent | None = None
233
+
234
+
235
+ def get_agent() -> UptimeAgent | None:
236
+ """The agent created by the most recent `uptime_agent()` call, if any."""
237
+ return _current
238
+
239
+
240
+ def uptime_agent(
241
+ *,
242
+ transport: HttpTransport | None = None,
243
+ on_config_error: str = "throw",
244
+ **options: Any,
245
+ ) -> UptimeAgent:
246
+ """Start the agent.
247
+
248
+ This is the only function in the package that can raise, and only on MALFORMED
249
+ configuration (`ConfigError`): a non-HTTPS endpoint, a missing `service`, an
250
+ unrecognised `mode`. Pass `on_config_error="disable"` for a warning and an inert
251
+ agent instead.
252
+ """
253
+ global _current
254
+ try:
255
+ if "hostname" not in options:
256
+ options["hostname"] = default_hostname
257
+ config = resolve_config(**options)
258
+ for w in config.warnings:
259
+ write_err(f"[uptimeagent] {w}\n")
260
+ agent = Agent(config, transport if transport is not None else UrllibTransport(config.env))
261
+ agent.set_context_provider(current_context)
262
+ if config.capture_exceptions:
263
+ install_exception_capture(agent)
264
+ if config.capture_process_events:
265
+ install_process_events(agent)
266
+ if config.capture_logging:
267
+ _install_logging(agent, config.capture_logging)
268
+ agent.start_heartbeat()
269
+ if config.debug:
270
+ s = agent.stats()
271
+ write_err(
272
+ f"[uptimeagent] {s.service} · {s.instance} · {s.environment} · {s.runtime}"
273
+ f" · mode={s.mode}{' · serverless' if s.serverless else ''}\n"
274
+ )
275
+ except ConfigError as e:
276
+ if on_config_error == "disable":
277
+ write_err(f"[uptimeagent] disabled — {e}\n")
278
+ _current = _InertAgent()
279
+ return _current
280
+ raise
281
+ _current = UptimeAgent(agent)
282
+ return _current
283
+
284
+
285
+ def _install_logging(agent: Agent, spec: Any) -> None:
286
+ level: int | None = None
287
+ logger_name = ""
288
+ if isinstance(spec, Mapping):
289
+ lv = spec.get("level")
290
+ if isinstance(lv, str):
291
+ level = (
292
+ logging.getLevelName(lv.upper())
293
+ if isinstance(logging.getLevelName(lv.upper()), int)
294
+ else None
295
+ )
296
+ elif isinstance(lv, int):
297
+ level = lv
298
+ logger_name = str(spec.get("logger") or "")
299
+ handler = UptimeAgentHandler(agent, level)
300
+ target = logging.getLogger(logger_name)
301
+ target.addHandler(handler)
302
+ agent.register_teardown(lambda: target.removeHandler(handler))