vitalwatch (Python)
Python reporter for VitalWatch Hub: server
errors, slow requests, heartbeat. Also ships
vitalwatch-agent, which watches the server itself: web
server, PHP-FPM, databases, systemd, Docker, OOM and disk.
No dependencies. No build step. Python 3.9+.
Sibling packages: cestnytr/vitalwatch-laravel (Laravel), vitalwatch-node (Node),
t.js (browser, in the hub repo at resources/sdk/t.js).
Scope — and why it is this narrow
This package covers the server side only. Browser errors are collected by t.js,
served from the hub; that file was deliberately not moved into a package — being hosted
means a fix reaches every site within minutes, whereas a package would require
redeploying every project for each fix.
Never collected — not even via configuration:
IP address · User-Agent · request body · query string values (path only) · session data · cookies · raw SQL literals
Installation
pip install vitalwatch
In your .env file — the same variable names as the sibling packages:
VITALWATCH_ENABLED=true
VITALWATCH_URL=https://vitalwatch.example.com
VITALWATCH_KEY=project-key
VITALWATCH_SECRET=<64-character secret from the dashboard>
VITALWATCH_ENV=production
Flask
from flask import Flask
from vitalwatch.flask import VitalWatchFlask
app = Flask(__name__)
VitalWatchFlask(app)
That one line installs: uncaught exceptions (teardown_request), 5xx and slow
requests (after_request), process-level hooks (sys.excepthook,
threading.excepthook) and an 8-hour heartbeat.
- One failure, one record: a failing request produces only an
exceptionwith a stack trace; no separatehttp_5xxis sent for the same request (0.1.1+). - If you have your own error handler: with
@app.errorhandler(Exception)orerrorhandler(500)defined, the error still arrives with its stack trace. If the handler returns a 4xx, nothing is sent. Previously the error was lost in this case and only a stack-lesshttp_5xxarrived. abort(4xx)is not sent: it is a deliberate response, not a failure.- Setup order does not matter:
VitalWatchFlask(app)can be called first andvitalwatch.init(...)later; both the request hooks and the process hooks (sys.excepthook,threading.excepthook) resolve the current reporter at call time. If there is no configuration at setup time, the heartbeat starts onceinit(...)is called. - Installing twice is harmless: a second
VitalWatchFlask(app)on the same app adds nothing; process hooks are installed once per process. - Never crashes your app: setup (including a broken or non-UTF-8
.env) never raises under any circumstances. - Disabling process hooks:
VitalWatchFlask(app, hook_process_errors=False)installs the request hooks only.
Django
# settings.py
MIDDLEWARE = [
"vitalwatch.django.VitalWatchMiddleware", # near the top of the list
# ...
]
The middleware times slow and 5xx requests and sends uncaught exceptions with their
stack trace. Errors are seen through two paths: the got_request_exception signal
Django emits while turning an exception into a 500 (which also covers errors raised in
lower middleware, not just the view) and process_exception. Same rules as Flask:
- One failure, one record; no separate
http_5xxfor a failing request. - 4xx is not sent:
Http404,PermissionDenied,BadRequest,SuspiciousOperation. - The route pattern is sent:
/product/<int:product_id>/, not the real id. Forre_pathpatterns the leading^and trailing$are stripped. - Errors are never swallowed:
process_exceptionreturnsNone; Django's 500 page andhandler500work as usual. - Process hooks are enabled once on first setup; set
VITALWATCH_HOOK_PROCESS = Falseto disable them.
FastAPI / Starlette (ASGI)
from fastapi import FastAPI
from vitalwatch.asgi import VitalWatchASGIMiddleware
app = FastAPI()
app.add_middleware(VitalWatchASGIMiddleware)
The same line works in Starlette (as does
Starlette(middleware=[Middleware(VitalWatchASGIMiddleware)])). Any other ASGI app can be
wrapped directly: app = VitalWatchASGIMiddleware(app). It is a pure ASGI middleware;
Starlette/FastAPI are not imported at import time.
- One failure, one record; 4xx
HTTPExceptionand validation errors (422) are not sent. A deliberateHTTPException(5xx)is recorded as a stack-lesshttp_5xx. - The route pattern is sent:
/product/{product_id}; theinclude_routerprefix andMountpath are prepended to the pattern. - If you have your own error handler:
@app.exception_handler(Exception)still routes the error through our middleware. A handler bound to a specific class (@app.exception_handler(ValueError)) that returns 5xx still delivers the error with its stack trace; one that returns 4xx sends nothing. Middleware order does not matter (anywhere inadd_middlewareormiddleware=[...]). For a manually wrapped app (VitalWatchASGIMiddleware(app)) this kicks in after the first request, because Starlette builds its stack on the first request; during that first request a 5xx becomes a stack-lesshttp_5xx. - Timing stops at the last byte of the response:
BackgroundTasksand post-response work do not count as slow requests. Long streams (SSE, large downloads) run until the last chunk and may be counted as slow. - Nested middleware records once: if a
Mounted sub-application also hasVitalWatchASGIMiddleware, the request is timed only by the outer one. - Errors are never swallowed: they are reported and re-raised; lifespan and websocket pass through untouched.
- Disabling process hooks:
app.add_middleware(VitalWatchASGIMiddleware, hook_process_errors=False).
Without a framework
import vitalwatch
vitalwatch.hook_process() # hooks + heartbeat
vitalwatch.report(error) # manual report
vitalwatch.init(ignore=(KeyboardInterrupt, "MyExpectedError")) # class or name
vitalwatch.start_heartbeat() # heartbeat only (hook_process already starts it)
vitalwatch.stop_heartbeat()
init() reads environment variables; arguments you pass override them. If it is never
called, it sets itself up on first use.
Verify the installation
vitalwatch-status
Do not skip this step. The package's most expensive failure mode is silently not
working: if the secret was copied incompletely, nothing breaks and nothing is logged —
the hub answers an invalid signature with 204 too. An installation can be dead for
months and that silence is mistaken for "all good".
vitalwatch-status --test # a real test event — shows up as an error in the dashboard
vitalwatch-status --vitalwatch # heartbeat only — does not create an error in the dashboard
--test is the right choice when verifying a single installation. In an update loop
that goes through dozens of installations, use --vitalwatch — --test would leave
dozens of fake errors in the dashboard, i.e. the monitoring tool would generate its own
noise.
Only the hub dashboard can confirm that data actually arrives: the connection status
on the project row should read Connected. The command cannot tell this on its
own, because the hub answers valid and invalid signatures identically to the outside.
Example output:
SDK version 0.2.3
Latest version 0.2.3
Enabled yes
Hub URL https://hub.example.com
Project key my-project
Secret length 64 characters
Environment production
Python 3.12.3
Release tag abc123def456 (source: .git)
✓ Configuration OK.
Only the hub panel can confirm that data arrives:
the project row should show the connection status "Connected".
The hub returns 204 for an invalid signature too, so this command cannot tell.
The command also shows:
Release tag— thereleaseattached to events and where it was found:abc123def456 (source: .git); the source isVITALWATCH_RELEASE, the name of a CI/PaaS variable,.gitornone(the tag itself then readsnot set). Details: Release tag.Latest version— the newest stable version on PyPI (info.version). If it is newer than the installed version, an! Update available: pip install -U vitalwatchline is printed; this is a warning and does not change the exit code. Without network access, or if PyPI does not respond, it printscould not check(2 s timeout). To never touch the network, setVITALWATCH_STATUS_OFFLINE=1(1,true,yes,on,evet; case-insensitive) — theLatest versionline is then omitted entirely.
Configuration
| Variable | Default | Description |
|---|---|---|
VITALWATCH_ENABLED |
true |
When false, no data is sent |
VITALWATCH_URL |
— | Hub URL (https) |
VITALWATCH_KEY |
— | Project key |
VITALWATCH_SECRET |
— | 64-character signing secret |
VITALWATCH_ENV |
APP_ENV |
production · staging · local. Aliases are mapped: prod/live → production, stage/stg/preprod/uat → staging, dev/development/test/testing → local. The hub rejects unknown values; the diagnostic command reports an error |
VITALWATCH_RELEASE |
auto-detected | Release tag. If empty, it is taken from CI/PaaS commit variables or from .git — see below |
VITALWATCH_STATUS_OFFLINE |
— | When 1/true/yes/on/evet, vitalwatch-status does not go online for the update check |
VITALWATCH_TIMEOUT |
2.0 |
Send timeout in seconds. Values of 100 and above are treated as milliseconds — Node expects ms under the same name, so 2000 should not mean 2000 seconds. Invalid, zero/negative, NaN or infinite → 2 s; the result is clamped to 0.1–10 s |
VITALWATCH_SLOW_REQUEST_MS |
1000 |
Slow request threshold |
Variables are read from os.environ and from the .env / .env.local / .env.<APP_ENV>
files; the process environment always wins. Only keys prefixed with VITALWATCH_ are
read.
Release tag (release)
VITALWATCH_RELEASE was left empty in almost every installation, so the hub could not
tell which deploy an error started with. Our deploys are mostly git pull + a
gunicorn restart on the server: the new process reads the commit itself on startup, and
the first error after a deploy shows the new release in the dashboard. The tag is
resolved once at setup (not per event); the first non-empty source wins:
- A value passed explicitly via
init(release=...). VITALWATCH_RELEASE— trimmed, at most 64 characters, used as is.- In order:
GIT_COMMIT,GIT_SHA,COMMIT_SHA,SOURCE_VERSION,VERCEL_GIT_COMMIT_SHA,RENDER_GIT_COMMIT,HEROKU_SLUG_COMMIT,CI_COMMIT_SHA. If the value is 7–40 hex digits, the first 12 in lowercase; otherwise trimmed, at most 64 characters. - The git checkout in the working directory:
.git/HEAD→ the branch ref file, falling back topacked-refs; on a detached HEAD, the sha itself. If.gitis a file (submodules etc.),gitdir:is followed. Only a valid 40-digit sha is accepted; the first 12 digits in lowercase. Thegitcommand is never run — files are read; if they cannot be read, it silently gives up.ref:may only point underrefs/and may not contain... Git worktrees (commondir) are not supported — the branch ref lives in the common directory and is not found; useVITALWATCH_RELEASEthere. - If none of these is available, no tag is sent.
The rule is the same as in the Node and Laravel packages. If gunicorn's working
directory is not the application root (no --chdir), step 4 finds nothing;
running vitalwatch-status from the same directory shows this as (source: none).
What gets reported
| Type | When |
|---|---|
exception |
Uncaught exception, report() call |
fatal |
MemoryError, RecursionError, SystemError — hitting the interpreter's limits |
http_5xx |
Response status ≥ 500 |
slow_request |
Duration ≥ VITALWATCH_SLOW_REQUEST_MS |
Successful, fast requests are not reported: sending every request would burden both the monitored application and the hub.
Server agent (vitalwatch-agent)
The SDK looks from inside the application. Failures outside it — nginx 502/504s,
PHP-FPM hitting pm.max_children, PostgreSQL FATAL, a crashed systemd service, a
Docker container stuck in a restart loop, the OOM killer, a full disk — can only be seen
from the server. vitalwatch-agent runs as a service on the server, watches for these
and sends them as error events to the hub's server endpoint. It is language-agnostic:
.NET, Go or a static site — whatever runs on the server, its web server, database and
service manager are monitored.
It ships in the same package, with no dependencies. Recommended: a separate hub project
for each server (e.g. vps-example), declared as agent.
Installation
sudo python3 -m venv /opt/vitalwatch
sudo /opt/vitalwatch/bin/pip install vitalwatch
sudo ln -s /opt/vitalwatch/bin/vitalwatch-agent /usr/local/bin/vitalwatch-agent
sudo install -d -m 700 /etc/vitalwatch
sudo nano /etc/vitalwatch/agent.ini # see the example below
sudo chmod 600 /etc/vitalwatch/agent.ini
sudo vitalwatch-agent check # sources and configuration
sudo vitalwatch-agent check --test # sends a test event to the dashboard
sudo sh -c 'vitalwatch-agent systemd > /etc/systemd/system/vitalwatch-agent.service'
sudo systemctl daemon-reload && sudo systemctl enable --now vitalwatch-agent
The virtualenv must live under /opt: ProtectHome=yes hides home directories and
PrivateTmp=yes hides /tmp from the service. The systemd command writes the
interpreter path of the running installation; if that path is in a home directory or
under /tmp, it relaxes the corresponding protection and notes it in a comment.
/etc/vitalwatch/agent.ini
[hub]
url = https://vitalwatch.example.com
key = vps-key
secret = <64 characters from the dashboard>
env = production
[sources]
# all default to "auto": enabled if present and readable. "on": check fails if missing.
nginx = auto
apache = auto
plesk = auto
php_fpm = auto
postgresql = auto
mysql = auto
mongodb = auto
redis = auto
journal = auto
systemd = auto
docker = auto
disk = auto
journal_errors = off ; all err-level journald lines (noisy)
docker_logs = ; container names whose logs to follow, comma-separated
[disk]
threshold = 90
[ignore]
hosts = ; the hub's own host is always added
patterns = ; one regular expression per line; matching raw lines are dropped
[log:plesk-panel]
path = /var/log/plesk/panel.log
pattern = \b(ERR|CRIT|ALERT|EMERG)\b
kind = log_error
Lookup order: --config → VITALWATCH_AGENT_CONFIG → /etc/vitalwatch/agent.ini.
The VITALWATCH_URL, VITALWATCH_KEY, VITALWATCH_SECRET, VITALWATCH_ENV and
VITALWATCH_ENABLED environment variables override [hub]. The agent does not read
.env files, so running check inside a project directory does not pick up that
project's key. Inline comments use ; only (regular expressions may contain #). The
state file is /var/lib/vitalwatch-agent/state.json (change it with
VITALWATCH_AGENT_STATE_DIR).
Commands
| Command | What it does |
|---|---|
vitalwatch-agent run |
Runs as a service. On SIGTERM it stops child processes, flushes the queue one last time and saves state |
vitalwatch-agent check |
Lists each source as found / not found / not readable / disabled; validates the hub configuration (64-character secret, https, environment name) |
vitalwatch-agent check --test |
Additionally sends a log_error test event and prints the HTTP status as is |
vitalwatch-agent systemd |
Prints a hardened unit file: root, ProtectSystem=strict, ReadWritePaths=/var/lib/vitalwatch-agent, NoNewPrivileges, ProtectHome, PrivateTmp, Restart=always |
The service runs as root because Plesk vhost logs and the Docker socket cannot be read
otherwise. The agent writes nowhere except its state file; it runs journalctl,
systemctl --failed, docker events and docker logs only to read from them. If
journalctl or docker is missing or not permitted, it prints a single warning to
stderr and keeps running.
What is collected
| Type | Source | Example |
|---|---|---|
web_server |
/var/log/nginx/*error*.log, /var/log/apache2/*error*.log, /var/log/httpd/*error*, Plesk /var/www/vhosts/system/*/logs/{error_log,proxy_error_log} |
[example.com] upstream timed out … while reading response header from upstream (+ route: GET /api/x, status: 504) |
php_fpm |
/var/log/php*-fpm.log, /var/log/php-fpm/*.log, /opt/plesk/php/*/var/log/php-fpm.log |
[pool www] server reached pm.max_children setting (5), consider raising it |
database |
/var/log/postgresql/*.log, /var/log/mysql/error.log, /var/log/mariadb/*.log, /var/log/mysqld.log, /var/log/mongodb/mongod.log, /var/log/redis/*.log |
FATAL: remaining connection slots are reserved … |
service_failed |
journald + systemctl --failed every 5 minutes |
php8.4-fpm.service: Failed with result 'exit-code' |
container_failed |
docker events |
api: exited with code 137, api: restart loop (3 in 10 min), api: unhealthy, api: killed by OOM |
oom |
journald (kernel, systemd-oomd) | Out of memory: Killed process <n> (php-fpm8.4) |
kernel |
journald (kernel) | php-fpm8.4[<n>]: segfault in libc.so.6, disk I/O errors |
disk_full |
Real filesystems from /proc/mounts |
/var: disk 93% full, /: inodes 91% used — at most one per mount point per hour |
log_error |
[log:<name>] sections, docker_logs, journal_errors = on |
the line matching the pattern |
- Each log is read from the end the first time it is seen; history is not imported. Positions (inode + offset) and the journald cursor are kept in the state file; log rotation and truncation are detected. Globs are rescanned every minute, so a new site added in Plesk is picked up without a restart.
- Noise is filtered out:
open() … failedfrom 404s, TLS handshake errors,[warn], PHP warnings (onlyFatal error/Parse errorget through), application-level SQL errors (that's the SDK's job), exit codes produced bydocker stop. - At most 20 events per minute for the same message and 300 events per minute in total. Events are sent every 5 s or every 25 events; bodies are split at 8 KB and 8 events per request. If the hub is unreachable, up to 1000 events are buffered and retried with exponential backoff.
- A heartbeat every 5 minutes (
{"events": [], "runtime": "agent", …}): if the agent stops, the project's server channel goes quiet and the hub's setup health shows it. - The hub's own domain (the host of
url) is always ignored: if the hub runs on the same server, its own errors must not feed the agent, and the agent's sends must not trigger new errors.
Deliberately not collected
- IP addresses — become
[ip]on every line; nginx'sclient:field is removed entirely. - Query strings and fragments — stripped from request lines, URLs and paths;
routecarries onlyGET /path. referrer/refererandhostheaders — removed entirely. The site name comes from the configuredserver_nameor the Plesk path, not from the client-suppliedhost.- Then the SDK's masking: e-mail, card numbers, phone numbers, Turkish national ID
(TCKN), IBAN, long tokens; quoted values in database messages become
?. - SSH / authentication logs (every line carries an IP and is meaningless without it),
access logs (
access.log— traffic, not errors; there is already a visits module) and mail logs (recipient addresses) are never read. Even withjournal_errors = on, sources such assshd,sudo,postfixanddovecotare skipped. - No CPU/RAM/disk graphs, continuous metrics or process lists: just a single event when a threshold is crossed.
Behavioral guarantees
- Errors are never swallowed. The handler chain is preserved;
raiseis never blocked. - No interference with process management.
sys.exitis never called; any existingexcepthookis chained. - It never reports its own errors. All SDK code is wrapped in
try/except. - It never delays user requests. Sending happens on a background thread with a short timeout.
- If the hub is unreachable, it silently gives up.
- No data is sent while
VITALWATCH_ENABLED=false.
Updating and versioning
The package is on PyPI: pip install -U vitalwatch installs the latest stable release.
If the version is not pinned, you always get the latest release; the trade-off is one
mandatory rule — every release must bump the version number. pip will not reinstall
a package whose version matches the installed one, and PyPI rejects a second upload with
the same number anyway. A fix shipped without a version bump never reaches installed
projects, and it happens silently — exactly the failure mode the whole package is
designed against.
Release steps:
- Bump
versioninpyproject.tomlandSDK_VERSIONinsrc/vitalwatch/reporter.pytogether. If they diverge, the dashboard shows the wrong SDK version. - Add an entry to
CHANGELOG.md. python -m pytest -q- Commit,
git tag vX.Y.Z,git push origin vX.Y.Z. The tag triggers thepublishworkflow: if the tests pass and the tag matches the package version, it is uploaded to PyPI. - In installed projects, run
pip install -U vitalwatch, then verify withvitalwatch-status. The dashboard should show the updated SDK version. For the server agent:/opt/vitalwatch/bin/pip install -U vitalwatch && systemctl restart vitalwatch-agent.
Tests
# For the framework tests — without these, 53 tests are silently skipped
pip install "Flask==3.0.0" Django fastapi starlette httpx
python -m unittest discover -s tests
If you see skipped= in the output, a framework is not installed and that integration
is untested (Flask 17, Django 12, FastAPI/Starlette 24 tests). That is exactly why
the double recording and the lost errors in 0.1.0 went unnoticed.
The tests run against a fake hub that replicates the hub's signature verification
(VerifyProjectSignature) and body limits exactly: this is where you see whether the
signature is really accepted — "I got a 204" is not enough.
Metadata
Release files for vitalwatch 0.2.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| vitalwatch-0.2.3.tar.gz | 108.8 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| vitalwatch-0.2.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 189.2 kB
Release files / vitalwatch-0.2.3.tar.gz
| Download URL | vitalwatch-0.2.3.tar.gz |
|---|---|
| Size | 108.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
26615b97458e6a00619a553a32983935d8652f57edcee035030c6dfd541f886f
|
|
BLAKE2b-256 checksum How to use checksums |
bb37fd31310afa42c8eb5725120e22f854934698012d102c5b27ecf6ce562fe7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logRelease files / vitalwatch-0.2.3-py3-none-any.whl
| Download URL | vitalwatch-0.2.3-py3-none-any.whl |
|---|---|
| Size | 80.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a1c0720b6ef55b71082f3f12c839c11eb640a63d16ccfef24a88643e3b7e721d
|
|
BLAKE2b-256 checksum How to use checksums |
5c7fedb81e0e35da9b2d8241d790b51e4355c2689bde4ab0af7881bb2c9007c9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logSecurity for vitalwatch 0.2.3
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for vitalwatch-0.2.3.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.