Proxy access logs (nginx, Traefik)
The SDK sees what your app did. The reverse proxy in front of it sees what your users got, and the two differ exactly when it matters:
- Requests the app never answered. While the app is down, restarting during a deploy, or too slow, the proxy answers 502, 503 or 504 and the SDK records nothing, because there was no app to record it. Sentrinel calls these not reached: a 5xx from the proxy with no answer from upstream.
- Clients that gave up. nginx logs them as 499, and they are invisible to the app.
- Where the time went. The proxy's total time minus the app's time is the network, a slow client, or buffering. If users see 2 s and the app took 80 ms, the problem is not in your code.
- Apps with no SDK. Static sites, admin panels and third-party containers behind the same proxy get status codes and latency with nothing installed in them.
- Bots, scanners and 404 floods.
All of this shows on the Edge page, per app.
Why it is separate from request logs
An app running the SDK is seen twice: once by the proxy and once by itself. If proxy lines were stored as request logs, every count, error rate and quota reading would double. Edge lines are kept apart. They are metered as log lines, not requests, and nothing that counts requests reads them.
Setup
1. An Edge key per app
In the dashboard, API keys โ Generate โ Proxy access logs. An Edge key can send access-log lines and nothing else, so a key on a proxy host cannot be used to write errors or read data.
One proxy usually fronts several apps. Give each app its own key; the collector's routes file decides which line goes to which app (step 3).
2. A log format with timing
Traefik (including Dokploy) already writes everything needed when JSON access logs are on:
# traefik.yml
accessLog:
filePath: /var/log/traefik/access.log
format: json
Dokploy writes /etc/dokploy/traefik/dynamic/access.log this way by default.
By default Traefik drops request headers from the log. Keeping three of them adds the user agent and links each line to the app's trace:
accessLog:
filePath: /var/log/traefik/access.log
format: json
fields:
headers:
names:
User-Agent: keep
Traceparent: keep
X-Request-Id: keep
nginx's default combined format has no timing and no upstream status, so
add a JSON format next to it:
log_format sentrinel escape=json
'{"time":"$time_iso8601","host":"$host","server":"$server_name","method":"$request_method",'
'"path":"$uri","status":$status,"request_time":$request_time,'
'"upstream_time":"$upstream_response_time","upstream_header_time":"$upstream_header_time",'
'"upstream_status":"$upstream_status",'
'"upstream":"$upstream_addr","bytes_in":$request_length,"bytes_out":$bytes_sent,'
'"client":"$remote_addr","ua":"$http_user_agent","request_id":"$request_id",'
'"protocol":"$server_protocol","tls":"$ssl_protocol"}';
access_log /var/log/nginx/sentrinel.log sentrinel;
proxy_set_header X-Request-Id $request_id;
$uri has no query string, so tokens in URLs never reach the file.
$upstream_header_time is what tells a 502 the app sent from one nginx made
up. When nginx cannot connect to the app, or gives up waiting, it still writes
502 or 504 into $upstream_status, but no headers ever arrived, so the header
time is -. Those lines are the ones counted as not reached.
The last line is optional and worth having. nginx's $request_id is 32 hex
characters, and the Sentrinel SDK accepts an incoming X-Request-Id in that
shape as the trace id. With it, each proxy line links to the trace of the
request the app handled.
3. The collector
curl -fsSL https://sentrinel.dev/install-edge.sh | \
sudo EDGE_LOG=/var/log/nginx/sentrinel.log SENTRINEL_KEY=snt_edge_โฆ bash
That installs sentrinel-edge as a systemd service running as an unprivileged
user, which is added to the adm group so it can read nginx's logs. With a
single key every line goes to that app. For several apps, edit the routes file
/etc/sentrinel/edge-default.routes:
# <host or router:glob> <key>
api.example.com snt_edge_โฆ
*.example.com snt_edge_โฆ
router:billing-* snt_edge_โฆ
The first match wins. router: matches Traefik's router name (without the
@provider suffix) or nginx's server_name. Lines that match nothing are
dropped and counted, never sent to the wrong app. Set EDGE_DEFAULT_KEY to
catch them instead.
Check the setup before relying on it:
sudo sentrinel-edge --check
It reads the last 200 lines and prints the detected format and a parsed sample. It then lists every host it saw with the key it matched, and verifies each key against the API.
What is sent, and what is not
| Default | Setting | |
|---|---|---|
| Query strings | stripped, on the collector and again on the server | always |
| Client IP | truncated: IPv4 to /24 (41.59.12.0), IPv6 to /48 |
EDGE_CLIENT_IP=full or none |
Static assets (.js, .css, images, fonts) |
skipped | EDGE_KEEP_STATIC=true |
| Request and response bodies, headers | never read | โ |
| User agent | sent, cut to 512 characters | โ |
The collector starts at the end of the file, so installing it does not replay
history. It keeps its position in /var/lib/sentrinel, and it survives log
rotation, whether the file is renamed or truncated in place.
Requirements
Edge logs are stored in ClickHouse, like database monitoring. A self-hosted Sentrinel on the Postgres-only telemetry store answers ingest with 501 and shows the setup card instead of data.