| Filename | Latest commit message | Latest commit date |
|---|---|---|
| LICENSE | ||
| proxyauth-dashboard-en.json | ||
| proxyauth-dashboard-fr.json | ||
| README.fr.md | ||
| README.md | ||
ProxyAuth — Grafana dashboard for Loki
Access-log dashboard for ProxyAuth, built on Grafana + Loki. 42 panels: traffic, latency, status codes, bandwidth, authentication failures, user-agent breakdown, referrers, and raw logs.
Version française de ce document : README.fr.md.
Files
| File | Language |
|---|---|
proxyauth-dashboard-en.json |
English |
proxyauth-dashboard.json |
French |
Both are identical apart from panel titles, legends and variable labels. The LogQL queries are the same. Import only one — they use different UIDs, so importing both gives you two independent dashboards.
Requirements
- Grafana 10.2 or later. The queries use the
$__autorange variable. - Loki, reachable as a Grafana datasource.
- Your ProxyAuth logs shipped to Loki with the stream label
app="proxyauth".
If you use a different label, see Adapting the stream selector.
Expected log format
The dashboard parses this access-log line, wrapped in ProxyAuth's JSON envelope
(the message field of an entry whose _target is proxyauth::access):
2026-09-02 11:36:32 [vhost] [ip] - METHOD HTTP/2.0 200 9075 /path [tid:-] 'user-agent' 'referer' 141620268 0.7% 177.7MB
Fields, in order: timestamp, vhost, client IP, method, protocol, status, response length, path, token id, user-agent, referrer, request time in nanoseconds, CPU usage, memory usage.
Parsing is done at query time by four independent regexp stages. If one stage
fails to match, the others still produce their fields — a malformed tail will not
blank out the whole dashboard.
Import
- In Grafana, go to Dashboards → New → Import.
- Click Upload dashboard JSON file and pick your file.
- Select your Loki datasource in the Loki dropdown.
- Click Import.
Configure the vhost list
vhost is parsed out of the log line, not a Loki stream label, so Grafana cannot
discover its values automatically. The list is hard-coded in the variable and
ships with the author's own vhosts.
Go to Dashboard settings → Variables → vhost and replace the comma-separated list under Custom options with your own hostnames.
If you would rather not maintain a list, change the variable Type to
Textbox and set the default value to .+. You then type a regex instead of
picking from a dropdown — for example .*\.example\.com.
Global filters
Four selectors at the top apply to every panel:
- Vhost — multi-select.
- Status —
All,2xx,3xx,4xx,5xx,401/403,429. Not applied to panels that already target one class, such as 5xx over range, so that picking2xxdoes not blank them. - Method — multi-select.
- Search — free-text regex on the raw line, applied before parsing. Leave at
.*to disable. This is the cheapest filter: Loki drops lines before running any regex.
Extracted fields
All fields are extracted at query time; none of them is a Loki stream label. That
matters in two ways: they cost nothing at ingestion and do not inflate your index,
but they also cannot be listed by label_values() and are subject to the series
limit when grouped on.
| Field | Type | Example | Extracted by |
|---|---|---|---|
vhost |
string | git.proxyauth.app |
stage 1 |
ip |
string | 192.168.1.254 |
stage 1 |
method |
string | POST |
stage 1 |
protocol |
string | HTTP/2.0 |
stage 1 |
status |
number | 200 |
stage 1 |
length |
bytes | 9075 |
stage 1 |
path |
string | /api/ds/query |
stage 1 |
tid |
string | - when unauthenticated |
stage 2 |
rt |
nanoseconds | 141620268 |
stage 3 |
cpu |
percent | 0.7 |
stage 3 |
mem |
bytes with unit | 177.7MB |
stage 3 |
ua |
string | full user-agent | UA parser |
bot |
string | Googlebot, curl |
UA parser |
browser |
string | Firefox, Chrome |
UA parser |
device |
string | Android, X11 |
UA parser |
referer |
string | - when direct |
referrer parser |
ref_host |
string | grafana.proxyauth.app |
referrer parser |
bot, browser, device and ref_host are derived: the regex captures a
short token rather than the whole string, which keeps cardinality low enough to
group on. ua and referer keep the full value and are the two fields most
likely to trip the series limit.
mem is read with unwrap bytes(mem), so the MB / GB suffix is converted to
real bytes rather than compared as a bare number. rt is in nanoseconds and
rendered with Grafana's ns unit, which rescales to µs / ms / s on display.
Panel reference
Key metrics
| Panel | What it shows |
|---|---|
| Requests / sec | Access-log line rate. The only panel that does not parse. |
| Parsed lines | Lines where stage 1 matched. Compare with your total to gauge parser coverage. |
| Tail parsed (rt/cpu/mem) | Lines where stage 3 matched. Diverging from the above means the line tail differs. |
| 5xx over range | Server error count over the selected range. |
| P95 latency | 95th percentile of rt. |
| Total transferred | Sum of length over the range. |
| Requests under 100 ms | Share of requests below 100 ms — a rough Apdex. |
| Authenticated traffic | Share of requests carrying a real tid. Stays at 0 % while all tokens are -. |
| Median response size | P50 of length. |
Charts
| Panel | What it shows |
|---|---|
| Traffic by status class | Stacked 2xx / 3xx / 4xx / 5xx rates. |
| Latency — P50 / P95 / P99 | Latency distribution over time. A P99 far above P50 points to a slow tail. |
| HTTP Status / Methods / Protocol | Proportional breakdown over the range. |
| CPU usage | Average cpu sampled per request. |
| Memory usage (RSS) | Process resident memory, a slow-moving value. |
| Response throughput (kB/s) | rate() over unwrapped length, split by vhost. |
| 4xx / 5xx error rate | Client and server errors side by side. |
| Bots vs browsers | Three exclusive buckets: bot, browser, unidentified. |
| Bot vs browser traffic | The same split over time — useful to spot a crawl burst. |
| Top user agents | Eight most frequent full user-agent strings. |
| P95 latency by vhost | Which backend is slow, rather than a global average. |
| Auth failures & rate limiting | 401, 403 and 429 as separate series. |
| Response size — P50 / P95 / P99 | Payload distribution; a heavy P99 explains bandwidth spikes. |
| Direct vs referred traffic | Requests with referer set to - versus the rest. |
Tables
| Panel | What it shows |
|---|---|
| Top Virtual Hosts | Traffic per vhost. |
| Top Paths (2xx/3xx) | Most requested legitimate paths. Restricted to successes to keep cardinality down. |
| Top Client IPs (2xx / 3xx / 4xx / 5xx) | Busiest clients per status class. |
| Top scanned paths (4xx) | Bot probing targets — /.env, /wp-config.php and friends. |
| Top authenticated token-ids | Busiest tokens. Empty while traffic is unauthenticated. |
| Top bots / crawlers | Crawlers by name, from the derived bot field. |
| Platforms | Android, iPhone, iPad, Windows, macOS, X11. |
| Browsers | Browser families. Edge is folded into Chrome. |
| User agents (detail) | Full user-agent strings, readable where the pie chart truncates. |
| Slowest paths (avg) | Mean rt grouped by vhost and path, over 2xx/3xx only. |
| Top IPs by auth failures | IPs accumulating 401 and 403 — the bruteforce signal. |
| Top referring domains | Referrer hostnames, domain only. |
Raw logs & debug
| Panel | What it shows |
|---|---|
| ProxyAuth Access Logs | Unparsed lines matching the current filters. |
| DEBUG — unparsed tail | Only lines where the tail regex failed. Empty means full coverage. |
Known limits
Series limit on high-cardinality panels
Loki's max_query_series defaults to 500. Panels grouping by path, ip or
ua can exceed it and show maximum number of series (500) reached. Wrapping the
query in topk() does not help: the limit applies during evaluation, before the
top-N reduction.
Several panels are deliberately narrowed to stay under it — Top Paths only covers 2xx/3xx, Top Client IPs is split per status class. To lift the restriction, raise the limit in your Loki config:
limits_config:
max_query_series: 5000
On Loki 3.3+, approx_topk is an alternative that never hits the limit. It
requires limits_config.shard_aggregations: approx_topk and is an experimental
feature.
User-agent classification
The Bots vs browsers panel uses a denylist of known crawler tokens plus a
generic *bot pattern. A scanner impersonating Firefox is counted as a browser.
Watch the Unidentified slice: if it grows, inspect the logs panel for recurring
user-agents and extend the pattern.
Edge is counted as Chrome, because its user-agent string contains Chrome/
before Edg/ and RE2 returns the leftmost match rather than honouring the order
of alternatives.
Troubleshooting
Everything says No data. Check the stream selector first. In Explore, run
{app="proxyauth"}. If that is empty, the label is wrong — see below.
Tables show a single unnamed row with a large count. The regex matched no
lines: sum by (label) collapsed everything into one anonymous series. Check the
Parsed lines stat at the top; it should be close to your total line count.
Latency, CPU or memory are empty. The tail of the line did not parse. Open the DEBUG — unparsed tail panel at the bottom; it lists exactly the lines that failed.
A panel returns 503. Almost always a range query using a [$__range] window,
which makes Loki rescan the full time range at every step. Use $__range only
with queryType: instant; use $__auto for time series.
invalid char escape. A variable value landed inside a double-quoted LogQL
string. ${var:regex} escapes dots as \., which Go string syntax rejects. Put
the value in backticks instead: vhost=~`${vhost:regex}` .
A filter blanks every panel. Inspect the interpolated query via
⋮ → Query inspector → Query. The panel definition can look correct while the
substituted value is wrong. Note that Grafana custom variables use
label : value order, not the reverse.
Adapting the stream selector
Every query starts with:
{app="proxyauth"} |= "proxyauth::access"
The Docker Loki driver does not set a job label; by default it only sets
container_name, plus compose_project / compose_service under Compose. Either
edit the selector in each panel, or add the label at ship time:
logging:
driver: loki
options:
loki-url: "http://loki:3100/loki/api/v1/push"
loki-external-labels: "app=proxyauth,container_name={{.Name}}"
Defining loki-external-labels replaces the default, so keep
container_name in the list. The container must be recreated, not just
restarted.
The |= "proxyauth::access" line filter keeps only access-log entries and
excludes ProxyAuth's other structured logs. Drop it if your setup ships access
logs to a dedicated stream.