LogQL Best Practices

The fastest LogQL queries on Kloudfuse touch the least data. Kloudfuse gives every log line two layers of structure before the raw text — indexed stream labels and ingest-time facets — and most string searches can be replaced by a filter on one of them. This page shows how to use each layer, in the order a query should apply them.

Know the three layers of a log line

Layer Examples Where to filter Cost

Indexed stream labels

source, level, kube_namespace, kube_service, host, cluster_name, pod_name, availability_zone, project

Stream selector: {source="nginx", level="error"}

Cheapest — resolved against the label index before any data is read

Ingest-time facets

Fields Kloudfuse extracted when the log arrived: statusCode, endpoint, duration, logger, and any other key your pipeline parses

Label filter, no parser needed: | statusCode="200"

Cheap — resolved from stored facet columns, no log-body scan

Raw log body

The original message text

Line filters: |= "text", |~ "regex"

Most expensive — every candidate line is reconstructed and scanned

Structure every query in that order: select streams by label, narrow by facet, and only then — if anything is still ambiguous — search the remaining text.

Start with the most selective stream selector

Every label matcher in the selector cuts the data the rest of the query reads. Name the source, and add every other indexed label you know:

{source="grafana", level="error"}

Two habits keep selectors fast:

  • Prefer = to =~. An equality matcher is a direct index lookup; a regex matcher has to be tested against every label value.

  • Never search for what a label already states. {source="app"} |= "ERROR" scans every line and still misses error and Error; {source="app", level="error"} reads only the error streams and catches all of them, because Kloudfuse normalizes the level label at ingest.

Avoid match-everything selectors such as {source=~".+"} — they turn the whole time range into the query’s working set.

Filter on facets, not on strings

Kloudfuse extracts facets from structured log content at ingest and stores them alongside the line. In LogQL they behave like labels that are already extracted — filter on them directly with a label filter expression:

{source="grafana"} | statusCode="200"

No json or logfmt stage is required, and no log body is scanned. Numeric, duration, and bytes comparisons work the same way:

{source="grafana"} | duration > 100ms

Compare this with the string-search version of the same intent, {source="grafana"} |= "statusCode=200", which scans every line, breaks if the field order or spacing changes, and also matches lines that merely mention that text.

One placement rule to remember: facets work in label filters, not in the stream selector. {source="grafana", statusCode="200"} matches nothing, because the selector only consults indexed stream labels. Write the facet condition after the opening selector instead:

{source="grafana"} | statusCode="200" | endpoint="queryData"

Reach for a parser only when the facet does not exist

Parsers (json, logfmt, pattern, regexp) re-extract fields from the raw text on every line, on every query. That work is wasted when the field is already a facet. Facets feed metric queries directly — grouping and unwrapping included:

sum by (statusCode) (count_over_time({source="grafana"} | statusCode != "" [5m]))
avg_over_time({source="grafana"} | unwrap duration(duration) [5m]) by (endpoint)

Use a parser when the field genuinely is not extracted at ingest — a one-off pattern inside the message body, or a format your pipeline does not parse:

{source="nginx"} | regexp "\"(?P<method>[A-Z]+) (?P<path>[^ ]+)" | method="POST"

Make string search the last filter, not the first

When you do need to search text, position and choice of operator matter:

  • Put line filters after the label and facet conditions, so they scan only the already-narrowed stream.

  • Put the most selective filter first when chaining several: |= "OutOfMemoryError" |= "payment" discards more per line than the reverse order.

  • Prefer |= (substring) to |~ (regex) for readability; on Kloudfuse both line filter forms are index-assisted, so choose the one that states the intent most simply and keep regex patterns anchored with literal prefixes.

  • Avoid searching for values that live in a label or facet — level="error" already covers ERROR, Error, and error, with no scan at all.

{source="payment", level="error"} | endpoint="checkout" |= "OutOfMemoryError"

Everything before |= in that query resolves from indexes and facet columns; the scan runs only over checkout-endpoint error lines.

Keep metric queries pushdown-friendly

Kloudfuse pushes metric computation down into the storage layer when the query shape allows it, which is dramatically faster than streaming raw lines back to the query engine. Two habits preserve that:

  • Group with by (…​), not without (…​). Aggregations using without are computed in the query engine instead of being pushed down — a 50× difference in the measurements below.

  • Keep formatting stages (line_format, and label_format with templates) out of metric queries. They force everything after them to run in the query engine; plain label renames (label_format status=statusCode) are fine.

sum by (level) (count_over_time({source="grafana"}[5m]))

Measured impact

The following measurements compare each practice against its anti-pattern on a production-scale Kloudfuse cluster (busiest measured source: ~2.4 million lines/hour). Times are the server-reported query execution time from the Loki API’s query statistics; log queries use a 10-minute range with a limit of 10, metric queries evaluate a [5m] window once.

Intent Best practice Anti-pattern Exec time Speedup

Find error logs

{source="grafana", level="error"}

{source="grafana"} |= "level=error"

9 ms vs 159 ms

~17×

Filter on a field

{source="grafana"} | statusCode="200"

{source="grafana"} |= "duration=" | logfmt | statusCode="200"

9 ms vs 159 ms

~17×

Aggregate by level

sum by (level) (…​)

sum without (host) (…​)

12 ms vs 621 ms

~50×

Count with a clean pipeline

count_over_time({…​}[5m])

count_over_time({...} | line_format "..." [5m])

28 ms vs 105 ms

~4×

Two notes on reading these numbers:

  • Absolute times depend on cluster size, data volume, and time range — the relative differences are the point, and they grow with data volume.

  • Substring (\|=) and regex (\|~) line filters measured within noise of each other on Kloudfuse (26–79 ms at these volumes) — both are index-assisted — so choose between them for clarity, not speed.

Bound the scope and the time range

Query cost scales with the data selected, and the time range is a multiplier on everything above. While iterating on a query, work against a short window — the last 5 or 15 minutes — and widen it only when the query is final. For dashboards, size the range window […​] to the panel’s step rather than defaulting to large windows: rate({…​}[1m]) at a 1-minute step reads each line once.

  • LogQL Limitations — where LogQL string search falls short structurally, and the FuseQL operators that avoid it.

  • Text search operators — FuseQL’s indexed term search, the fastest way to search log text on Kloudfuse.

  • Predicate operators — the full label filter reference: string, numeric, duration, and bytes comparisons.