LogQL
LogQL is deliberately PromQL's sibling: the same label matchers, the same range windows, the same aggregation grammar. A query starts by selecting streams with labels, filters lines like grep, optionally parses fields out of the line at query time, and can finish by turning the whole thing into a metric.
Which means the mental toolkit you built in Chapter 4 already covers most of this topic. What is genuinely new is small: line filters, query-time parsers, and the discipline of ordering stages so the expensive ones see the fewest lines. Everything else is PromQL wearing a log costume.
Stream Selectors
Every query opens with a stream selector: {job="bookings", env="prod"}, using PromQL's matcher syntax — =, !=, =~, !~ — against stream labels. The selector is the only part of a query the index accelerates, so it does the heavy narrowing before anything touches chunk data. Topic 34 said selectors are the only index Loki has; this is where you spend that index.
Line Filters
Next come line filters: |= "timeout" for substring match, != "healthcheck" to exclude, |~ "pool.*exhausted" for regex. They run over raw line bytes and are the cheapest per-line operation LogQL has. Chain them — |= "error" != "retrying" — and millions of lines narrow to hundreds before any expensive stage runs.
Parsers and Label Filters
The stage | json (or | logfmt) extracts fields from line content into labels at query time. This is the other half of topic 34's bargain: structure paid for on read instead of indexed on write. Extracted labels then feed label filters — | level="error", | wait_ms > 3000 — and lines that fail to parse do not vanish; they get an __error__ label you can inspect or drop deliberately. Ignore __error__ over mixed-format streams and your "all errors" panel quietly undercounts.
Metrics from Logs
The last stage turns logs into numbers. Wrap a filtered stream in rate() with a range window and you get a per-second rate; count_over_time counts raw occurrences in the window; sum by (job) aggregates across services. And unwrap pulls a numeric field out of each line so quantile_over_time can compute percentiles from it — a latency distribution conjured from logs, no instrumentation change required.
# per-second rate of pool timeouts, computed from logs alone
rate({job="bookings"} |= "connection pool timeout" [5m])
# p95 of the wait_ms field — a percentile with no code change
quantile_over_time(0.95,
{job="bookings"} |= "conn_pool_timeout" | json | unwrap wait_ms [5m])
The first query is the log-derived twin of a counter rate from Chapter 4. The second filters to the pool-timeout events, parses each line, unwraps the numeric wait_ms field, and computes the 95th percentile of it over 5-minute windows — Mara now knows not just how many requests waited, but how long the slow ones waited.
One Toolkit with PromQL
Because rate, range vectors, by/without, and the arithmetic operators behave the same in both languages, a Grafana panel can graph harborline_bookings_total errors and log-derived timeout rates side by side, and your instincts transfer whole. Mara's Saturday-morning query takes her 30 seconds to write, because Chapter 4 already taught its grammar.
{job="bookings", env="prod"}
|= "connection pool timeout"
| json
| level="error"
Selector first, cheap substring filter second, parser third, label filter last — the cost-ordering rule applied. The result: hundreds of matching lines between 09:00 and 11:30, all from bookings, every one a request that waited more than five seconds for a connection. For the first time the Saturday complaint has a name and a count. What the lines still cannot say is where those five seconds went inside each request — that answer needs the third signal, and it arrives in Chapter 8.
- Putting the parser before the line filter —
| json | msg=~".*timeout.*"parses every line in range and then discards most of them, where|= "timeout" | jsonfilters raw bytes first and runs roughly 10x cheaper on big ranges. - Confusing
count_over_timewithrate— one is a count in the window, the other is per-second; an alert threshold copied between them is silently off by the window length, a factor of 300 for a 5-minute window. - Running
| jsonover streams with mixed formats and never checking__error__— nginx's non-JSON lines fail parsing, get excluded by later label filters, and the "all errors" panel quietly undercounts. - Unanchored greedy regex like
|~ ".*pool.*timeout.*"across a 7-day range — regex is the priciest line filter, and a substring|=pre-filter in front of it is the difference between 2 seconds and a timeout. - Graphing a raw log query where a metric query belongs — a dashboard panel streaming thousands of matched lines on every refresh, when
sum(rate(...[5m]))was the question actually being asked.
- Order every query selector → line filters → parser → label filters, cheapest narrowing first, so the expensive stages see the fewest possible lines.
- Reach for the
json/logfmtparsers plus label filters instead of regex-capturing fields by hand — the structured logging from topic 32 exists precisely so queries stay this simple. - Reuse PromQL instincts deliberately —
rateis per-second, aggregations needby, ranges need[5m]— and treat any divergence between your two mental models as a bug to fix. - Promote a repeatedly-run log metric to a Loki ruler recording rule, so the scan happens once a minute instead of on every dashboard refresh.
Knowledge Check
Which stage of a LogQL query does Loki's index accelerate?
- The line filters, since substring matching is indexed
- The stream selector; every stage after it scans chunk data
- The json parser, which reads a pre-built field index
- All stages equally, since Loki plans the query as a whole
Why is |= "timeout" | json much cheaper than | json | msg=~".*timeout.*" on a large range?
- The first query returns fewer results, so it transfers less data
- The json parser runs faster after a line filter has warmed the cache
- The cheap filter runs first, so the parser only sees surviving lines
- Loki rewrites both to the same plan, so only readability differs
An alert threshold built on rate(...[5m]) is copied onto a count_over_time(...[5m]) query. What breaks?
- The threshold is off by the window length — 300x for 5 minutes
- Nothing — the two functions are aliases over the same math
- count_over_time errors out because it needs an unwrapped field
- The [5m] range syntax is invalid for count_over_time
What does | json actually do, and how do unparseable lines surface?
- It parses lines at ingest and stores the fields in the index so later queries skip parsing
- It extracts fields into labels at query time; failures get an __error__ label
- It silently deletes any line that is not valid JSON
- It rewrites stored chunks so future queries parse faster
You got correct