Guide
Logthread Query Language (LTQL)
LTQL is how you search, parse and aggregate logs in Explore. A query is the familiar search box, optionally followed by pipe stages that parse the message, filter on what you parsed and aggregate the result.
service:checkout severity:(error|warn)
| json
| where status >= 500 and duration_ms > 250
| stats count(), p95(duration_ms) by routeType | in the search box (or press Pipeline) to open the pipeline editor. Ctrl/⌘+Enter runs the query. Nothing runs in your browser: the server parses every query, and a mistake is reported with its line and column.
Learn it in seven steps#
Each step builds on the one before. Every example below is checked by a test, so it parses.
1. Basics. Type words. Every word must appear in the message or in an attribute value, in any order.
checkout timeout2. Filters. Add field:value filters for service, severity and attributes. Numbers compare with >, >=, <, <=.
service:api severity:(error|warn) gw.status>=5003. Parse. After a |, read fields out of the message and filter on them.
service:api | json | where duration_ms > 500 | sort duration_ms desc | limit 204. Aggregate. stats turns rows into a table. The default column for p95(duration_ms) is p95_duration_ms.
service:api | json | stats count(), p95(duration_ms) by route | sort p95_duration_ms desc5. Chart. Group by bin(ts, auto) and the result also draws as a time chart.
service:api | stats count() by bin(ts, auto), severity6. Alert. In Explore choose Create alert. A query that ends in exactly one aggregate and has no by becomes a computed-value rule. See Alerts.
service:gateway | stats p95(attr.gw.duration_ms)7. Dashboard. Choose Add to dashboard on a stats query, or add a Logs query to a panel and set Show to the stats table or series. On a dashboard bin(ts, $__interval) means the same as bin(ts, auto). See Dashboards.
service:gateway | stats count() by bin(ts, auto), attr.gw.status1. The selector#
Everything before the first | is the selector. It works exactly as before.
| You type | Meaning |
|---|---|
checkout timeout | both words appear in the message or an attribute value, in any order (case-insensitive) |
"timed out" | the exact phrase appears in the message or an attribute value (up to 8 words or phrases per search) |
* | every log (use it before a | stage: * | stats count() by service) |
service:api | service equals api |
service:(api|web) | service is api or web |
-severity:debug | not that value |
severity:(error|warn) | levels fatal, error, warn, info, debug, trace, unknown |
http.status:500 | attribute http.status equals 500 |
gw.status>=500, gw.duration_ms<200 | compares numbers: >, >=, <, <= (also written gw.status:>=500). A value that is not a number never matches |
res.host.name:web-1, res.host.ip:10.0.0.4 | resource attribute (the agent sets both on every record) |
user:* / -user:* | attribute exists / is missing |
body~"timed out" | contains |
service == "api" and (env == prod or env == staging) | the expression form |
When a search finds nothing and filters on a field that no record in the time range has, Explore says so and suggests similar field names that exist: "No record in this range has gw.statuss. Did you mean gw.status?". Click a suggestion to fix the search. A service that sent nothing in the range is named the same way, with the close names that did: "No service gatewy sent logs in this range (did you mean gateway?)".
Comparisons take a number after the operator. For "not above" use the opposite comparison (gw.status<500), not a minus. severity is a level, so compare it with severity:(error|fatal) instead.
A | inside quotes or parentheses belongs to the selector, so quote text that contains one: body~"a | b".
2. Fields#
In stages you can use:
- the built-in fields
ts,service,severity,body,trace_id,span_id; attr.<key>andres.<key>for attributes and resource attributes (a bare name meansattr., unless a stage parsed it);- names created by an earlier
json,logfmt,regexporpatternstage. A parsed name wins over an attribute with the same name; writeattr.nameto force the attribute.
A field whose name has odd characters goes in quotes: where "http.request id" = "abc".
Comments start with # and run to the end of the line.
3. Parse stages#
All parse stages read the message. Rows the parser cannot read are kept; their parsed fields are empty, so use where exists(field) to drop them.
json#
| json # keys you use later are extracted
| json user_id=user.id, ms=timing.total # alias = nested path
| json status, route # only these fieldsNested keys use a dotted path (user.id). Values that are objects or arrays arrive as JSON text. With no names, every top-level key is shown as a column you can add.
logfmt#
| logfmt
| where retries > 1 and queue = "emails"Reads key=value pairs. Values may be quoted: msg="queue slow".
regexp#
| regexp "(?P<method>[A-Z]+) (?P<path>\S+) HTTP/[\d.]+\" (?P<status>\d{3})"Named groups become fields. Use RE2 syntax: lookahead, lookbehind and backreferences (\1) are not supported and are rejected with a message. A string in backticks is raw, which is handy for regexes: ` (?P<id>\d+) `.
pattern#
| pattern "<ip> - - [<_>] \"<method> <path> <_>\" <status> <bytes>"A LogQL-style pattern: <name> captures text up to the next literal, <_> skips text. It matches from the start of the message.
4. Row stages#
where#
| where status >= 500 and (path ~ "/api/" or path =~ "^/v[0-9]/")
| where severity in ("error", "warn") and not exists(attr.retry)| Operator | Meaning |
|---|---|
= != | equal, not equal |
> >= < <= | order. A number on the right compares numerically |
~ !~ | contains, does not contain (case-insensitive) |
=~ | matches an RE2 regular expression |
in (a, b) / not in (...) | one of a list (up to 50 values) |
exists(field) | the field is present |
and or not ( ) | combine; not binds tightest, then and, then or |
Numbers compare as numbers (status >= 500); text compares as text (status = "500"). A row whose field is missing or not a number never satisfies a numeric comparison. ts compares with a quoted RFC 3339 time: ts >= "2026-10-18T10:00:00Z". severity takes class names.
fields, sort, limit#
| fields ts, service, method, path, status # the columns, in order
| sort duration_ms desc, ts asc # default order is newest first
| limit 200 # 1 to 1000sort and limit produce one page. Without them you keep scrolling for more.
5. stats#
| stats count(), p95(duration_ms) as p95 by service, bin(ts, 1m)| Function | Result |
|---|---|
count() | events |
count_distinct(f) | distinct values |
sum(f) avg(f) min(f) max(f) | over a numeric field |
p50(f) p90(f) p95(f) p99(f) | percentiles |
- Up to 5 aggregates and 3 group fields.
as namerenames a result column; the default iscount,p95_duration_ms, and so on. bin(ts, auto)groups by time and adds a chart, with a step sized to the range (about 150 to 400 points). A fixed step,bin(ts, 30s | 5m | 1h | 1d), is the finest you want: when the range would need more than 1,000 points it is widened to the next step that fits, and the chart says so. On a dashboard,bin(ts, $__interval)is the same asbin(ts, auto). A| statsquery may cover up to 366 days within your retention; event searches cover up to 7 days.statsis the last stage, apart fromsortandlimit, which apply to the table and may name any of its columns.
6. Limits#
- 4,000 characters and 10 stages per query; strings in a regexp or pattern are at most 512 characters.
- Searches that return event rows or facets cover at most 7 days. A
| statsquery covers up to 366 days, within your workspace retention. - RE2 only, no lookaround or backreferences.
- A query that reads too much data is stopped with advice: narrow the time range, or add a
service:filter before| json. - A query may contain at most 20 numeric comparisons; combine values with
in (...).
7. Columns#
The results table can show any field, not only the message.
- Open Columns to add a core field, an attribute, a resource attribute or a field your pipeline parsed; remove columns; reorder them by dragging or with the arrow buttons; reset. The message is a column too and can be hidden.
- In an event's details, the action Add as column is next to the filter buttons of every field.
| fields a, bsets the columns when you run it. The URL carries them ascols=ts,service,attr.region, and when both existcols=wins. Saved searches keep their columns, and Use as my default here sets the columns new searches start with.- Click a value in a column to filter on it. Numbers are right-aligned; long values are cut with the full text in a tooltip.
- Live tail shows the same attribute and resource columns. It does not compute parsed fields or apply stages.
8. Examples#
1. Slow requests in a JSON log
service:api | json | where duration_ms > 500 | sort duration_ms desc | limit 502. nginx access log: errors by path
service:nginx
| pattern "<ip> - - [<_>] \"<method> <path> <_>\" <status> <bytes>"
| where status >= 500
| stats count() by path | sort count desc3. logfmt: retries by queue
service:worker | logfmt | stats sum(retries) as retries, count() by queue4. p95 latency by route
service:checkout | json | stats p95(duration_ms) as p95, count() by route | sort p95 desc5. Top error messages
severity:(error|fatal) | json | stats count() by msg | sort count desc | limit 206. Error rate over time
service:api | json | stats count() by bin(ts, 5m), severity7. One customer's requests, chosen columns
service:api | json | where user.id = "u-42" | fields ts, method, path, status, duration_ms8. Find a regex in the message
service:api | where body =~ "timeout after [0-9]+ms"9. Named regexp groups, then filter
service:proxy | regexp "(?P<method>[A-Z]+) (?P<path>\\S+) (?P<status>\\d{3})" | where status =~ "^5"10. Distinct users per service
| json user=user.id | stats count_distinct(user) as users by service | sort users desc9. Coming from another language#
| You know | LTQL |
|---|---|
LogQL {app="api"} |= "error" | service:api error |
LogQL | json | status >= 500 | | json | where status >= 500 |
LogQL | logfmt | level="error" | | logfmt | where level = "error" |
LogQL | regexp "(?P<x>...)" | | regexp "(?P<x>...)" (RE2, the same) |
LogQL | pattern "<ip> - <_>" | | pattern "<ip> - <_>" |
LogQL sum by (route) (rate({app="api"}[5m])) | service:api | stats count() by bin(ts, 5m), route |
LogQL quantile_over_time(0.95, ... | unwrap ms [5m]) | | json | stats p95(ms) by bin(ts, 5m) |
Datadog service:api status:error @http.status_code:>=500 | service:api severity:error | where http.status_code >= 500 |
Datadog @duration:>1000 | | json | where duration > 1000 |
Datadog Group by @http.route, measure pc95(@duration) | | stats p95(duration) by route |
Splunk index=app error | rex "(?<status>\d{3})" | app error | regexp "(?P<status>\d{3})" |
Splunk | where status>=500 | | where status >= 500 |
Splunk | stats count by host | | stats count() by attr.host |
Splunk | timechart span=1m count | | stats count() by bin(ts, 1m) |
Splunk | stats p95(ms) by route | | stats p95(ms) by route |
Splunk | table _time host msg | | fields ts, host, msg |
Splunk | sort -ms | head 10 | | sort ms desc | limit 10 |
10. Using LTQL elsewhere#
To look up one request, order or trace id without knowing the field, type req:<id> as the whole query in Explore, or use Find request on Home. See Explore.
The same query string works in saved searches and in the MCP server's search_logs tool. Fields that a pipeline parsed into attributes (for example gw.organization and gw.status) are ordinary attribute names there too: service:gateway gw.organization:abc | stats count() by gw.status filters on the attribute and groups by the other. From a script or an AI agent, run LTQL through the MCP server's search_logs tool with an API key; the web app's own POST /api/explore/query needs a signed-in browser session and does not accept API keys. A stats query returns {"kind": "table", "columns": [...], "rows": [...], "series": ...}; other queries return rows with a fields object for parsed or selected columns. See What an API key can call over HTTP.
11. Reference card#
Field names. Names are case-insensitive for the built-in fields, and these aliases mean the same thing:
| Field | Also written |
|---|---|
service | svc, app, service.name, service_name |
severity | level, lvl, sev, severity_text, status (a value that is not a level, such as status:500, is searched as the attribute status) |
body | msg, message, text, log |
trace_id | trace, traceid, trace.id |
span_id | span, spanid, span.id |
Other names are attributes: @http.status, attributes.http.status and attr.http.status are the same field; resource.host.name and res.host.name are the same resource attribute. A leading @ is ignored.
Selector operators.
| Form | Meaning |
|---|---|
field:value, field=value | equals (-field:value and field!=value negate it) |
field:(a|b), field:[a,b] | one of; up to 50 values |
field:*, -field:* | exists, does not exist |
field~"text", field:~"text" | contains, case-insensitive |
field>=n, field:>=n | numeric comparison; >, >=, <, <= |
Severity names. fatal (also critical, crit, panic, emerg, alert), error (err), warn (warning), info (notice, information), debug, trace, unknown. Anything else is rejected with the list.
Expression form. A selector that uses ==, &&, ||, !, in [...], exists(...), contains(field, "text") or the words and / or / not is read as an expression:
contains(body, "timeout") and level in ["error", "fatal"]service == "api" && (env == prod || env == staging)== and = are the same, ≥ ≤ ≠ outside quotes mean >= <= !=, and asc / desc order a sort key. In stages, true and false are values: | where ok = true. &&, || and ! belong to the expression form of the selector; inside a where stage write and, or and not.
Limits.
| Limit | Value |
|---|---|
| Query | 4,000 characters, 10 stages, one each of fields, sort, limit and stats |
sort | 3 keys |
fields | 30 names |
json | 20 paths, 8 levels deep, each key at most 128 characters |
| Parsed fields | 32 per query; a parsed name cannot be a built-in field or start with attr. or res. |
limit | 1 to 1,000 |
stats | 5 aggregates, 3 group fields, one bin() |
stats rows | 500, or 5,000 when grouped by bin() |
| Chart series | 20 (the largest are kept, the chart says so) |
in (...) | 50 values |
| Conditions | 100 terms and 20 numeric comparisons per query |
12. Not supported#
These are rejected with a message or behave as shown, so you are not left guessing.
| You might try | What happens | Use instead |
|---|---|---|
service:api* | * is part of the value: it matches a service literally named api* | service:(api|api-worker) or service~"api" |
-"health check" | A leading - on free text is searched literally, not as a negation | | where body !~ "health check" |
-body~"x" | Negated contains is not accepted in the selector | | where body !~ "x" |
not (a == 1 and b == 2) | Rejected: negating a group | | where not (a = 1 and b = 2) |
env not in ["dev"] (selector) | Rejected | -env:dev, or | where env not in ("dev") |
env == "prod" or service == "api" | Rejected: or only joins values of one field | Two searches, or | where service = "api" or env = "prod" |
| top 5 by x, | timechart, | eval | Unknown stage | | stats count() by x | sort count desc | limit 5, bin(ts, 1m) |
rate() | Not an aggregate | count() grouped by bin(ts, 1m) |
where a = b | A field compared with another field is rejected: the right side is a string or number | Compare to a value |
where severity > "warn" | severity supports =, != and in only | severity:(error|fatal) |
where path =~ "api" | =~ matches anywhere in the value and is case-sensitive; it is not anchored | ^, $ or (?i) in the expression, or ~ for a case-insensitive contains |
Two bin() keys, two stats stages | Rejected | One of each |
Next: turn a query you trust into an alert with a delivery channel.
Coming from Loki: LogQL works too#
Paste LogQL into Explore, the AI agent tools (search_logs, validate_query) or the API, and Logthread runs the LTQL it becomes and shows it, so you can keep your queries while you learn LTQL:
| LogQL | Runs as |
|---|---|
{service="gateway"} |= "error" | service:gateway "error" |
{app="x"} | json | status >= 500 | service:x | json | where status >= 500 |
sum by (service) (count_over_time({service="api"}[5m])) | service:api | stats count() by bin(ts, auto), service |
Labels service_name, service, app, job and container become service:, level becomes severity:, other labels are attribute filters; line filters (|=, !=, |~, !~), the json, logfmt, regexp and pattern parsers, label filters and count_over_time, rate, sum_over_time, avg_over_time, max_over_time, min_over_time and quantile_over_time (with unwrap) translate. A query that does not translate, such as count(...) over series, is refused with the reason, so you can write it in LTQL. Grafana dashboards and alert rules with LogQL come over with Import from Grafana.