“Some checkouts are slow and nobody can say which query. The answer was already in Postgres's own logs.”
case name: The database was telling us all along
- Service
- postgres
- Impact
- 10% of checkouts slow (the Case 0.2 tail)
- Detected by
- p99 latency panel
- Time to resolve
- 30 min
Skills you'll use on this case
pattern parserunwrap to turn log fields into numbersquantile_over_timeslow-query logging00 It starts
Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.
01 The investigation
- 13:05
ALERT
p99 /checkout above 1 s — the familiar slow tail
Traces (Case 0.4) would point at 'the database'. This time, ask the database itself which statements are slow.
- 13:08
QUERY
Postgres logs are plain text, not JSON
LogQL· Loki{container=~".*postgres.*"} |= "duration:"result2026-09-26 13:08:02.418 UTC [212] LOG: duration: 3003.271 ms statement: select pg_sleep(3) 2026-09-26 13:07:57.102 UTC [209] LOG: duration: 3002.884 ms statement: select pg_sleep(3) ... - 13:12
QUERY
Parse it with
pattern: literal text plus named capturesThe
patternparser matches the line against a template:<name>captures a field,<_>skips one, and everything else must match literally. It's much easier to read than a regex for fixed-format logs like Postgres, nginx, or HAProxy.LogQL· Loki{container=~".*postgres.*"} |= "duration:" | pattern "<_> <_> <_> [<pid>] LOG: duration: <duration> ms statement: <statement>" | line_format "{{.duration}} ms {{.statement}}"result3003.271 ms select pg_sleep(3) 3002.884 ms select pg_sleep(3) ... - 13:16
QUERY
Turn the parsed duration into a metric — by statement
unwrap durationmakes the extracted field the sample value, so range functions likequantile_over_timeandavg_over_timecan compute on it. That gives latency per SQL statement straight from Postgres logs, with no metrics exporter.LogQL· Lokiquantile_over_time(0.99, {container=~".*postgres.*"} |= "duration:" | pattern "<_> <_> <_> [<pid>] LOG: duration: <duration> ms statement: <statement>" | unwrap duration [10m] ) by (statement)result{statement="select pg_sleep(3)"} 3003.9 - 13:35
RESOLVED
The slow statement is identified and fixed
In real life you'd see a genuine statement here, such as a missing index on
orders(product_id)or a sequential scan, and you'd runEXPLAIN ANALYZEon it next.
Root cause
A specific SQL statement was intermittently slow. Postgres logs every statement over the threshold with its exact duration and text, but nobody collected or parsed those logs. Once parsed with pattern, they gave per-statement latency directly.
02 The concepts behind it
Parsers: json, logfmt, pattern, regexp
| json and | logfmt for structured logs. | pattern "..." for fixed-layout text logs: fast and readable. | regexp "..." with named groups when the layout varies. All extract fields at query time, which is the Loki way (Case 0.3), so parsing a new log format never requires reindexing.
Logs as metrics: unwrap
Many systems log numbers you'd love as metrics: durations, sizes, counts. | unwrap field turns a parsed numeric field into the sample value for avg_over_time, quantile_over_time, sum_over_time, max_over_time, and friends. It's perfect for exploration and for systems you can't instrument, but for high-volume, always-on use a real metric is much cheaper to query.
Third-party software already emits signals
Databases, proxies, queues, and runtimes have slow-query logs, access logs, and stats views (pg_stat_statements), and usually Prometheus exporters (postgres_exporter, redis_exporter). Collecting and parsing what they already produce is often the fastest win in observability.
03 The fix
01Switch the scenario off
terminal$ curl -s -X POST localhost:8080/admin/chaos -H 'content-type: application/json' -d '{"scenario":"slow-checkout","enabled":false}'── expected output ──{"active":[]}
04 Make sure it never surprises you again
01Keep slow-query logging on, and add postgres_exporter
log_min_duration_statement(e.g. 500 ms) is cheap and invaluable. For continuous metrics, runpostgres_exporterand enable thepg_stat_statementsextension, then graph mean and total time per query with PromQL instead of parsing logs.obs-lab/docker-compose.ymladd to fileyaml postgres-exporter: image: quay.io/prometheuscommunity/postgres-exporter:v0.17.1 environment: DATA_SOURCE_NAME: postgresql://postgres:shoplite@postgres:5432/shoplite?sslmode=disable # then add a scrape job for postgres-exporter:9187 in prometheus.yml
05 Your turn: write the query
Run each one against your lab before revealing the answer. Share your own version in the comments; there's usually more than one correct query.
Write a LogQL query counting slow statements per minute, grouped by statement text.
Using pino-http's JSON logs instead of metrics, compute ShopLite's average responseTime per req_url over 5 minutes.
06 Interview questions from this case
How would you get latency data out of a system that only writes text logs?
What's log_min_duration_statement in Postgres and how would you use it?