Early access is open. Free plan during early access. Start free
Logthread

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 route

Type | 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 timeout

2. Filters. Add field:value filters for service, severity and attributes. Numbers compare with >, >=, <, <=.

service:api severity:(error|warn) gw.status>=500

3. 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 20

4. 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 desc

5. Chart. Group by bin(ts, auto) and the result also draws as a time chart.

service:api | stats count() by bin(ts, auto), severity

6. 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.status

1. The selector#

Everything before the first | is the selector. It works exactly as before.

You typeMeaning
checkout timeoutboth 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:apiservice equals api
service:(api|web)service is api or web
-severity:debugnot that value
severity:(error|warn)levels fatal, error, warn, info, debug, trace, unknown
http.status:500attribute http.status equals 500
gw.status>=500, gw.duration_ms<200compares 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.4resource 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> and res.<key> for attributes and resource attributes (a bare name means attr., unless a stage parsed it);
  • names created by an earlier json, logfmt, regexp or pattern stage. A parsed name wins over an attribute with the same name; write attr.name to 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 fields

Nested 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)
OperatorMeaning
= !=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 1000

sort 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)
FunctionResult
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 name renames a result column; the default is count, 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 as bin(ts, auto). A | stats query may cover up to 366 days within your retention; event searches cover up to 7 days.
  • stats is the last stage, apart from sort and limit, 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 | stats query 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, b sets the columns when you run it. The URL carries them as cols=ts,service,attr.region, and when both exist cols= 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 50

2. nginx access log: errors by path

service:nginx
| pattern "<ip> - - [<_>] \"<method> <path> <_>\" <status> <bytes>"
| where status >= 500
| stats count() by path | sort count desc

3. logfmt: retries by queue

service:worker | logfmt | stats sum(retries) as retries, count() by queue

4. p95 latency by route

service:checkout | json | stats p95(duration_ms) as p95, count() by route | sort p95 desc

5. Top error messages

severity:(error|fatal) | json | stats count() by msg | sort count desc | limit 20

6. Error rate over time

service:api | json | stats count() by bin(ts, 5m), severity

7. One customer's requests, chosen columns

service:api | json | where user.id = "u-42" | fields ts, method, path, status, duration_ms

8. 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 desc

9. Coming from another language#

You knowLTQL
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:>=500service: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:

FieldAlso written
servicesvc, app, service.name, service_name
severitylevel, lvl, sev, severity_text, status (a value that is not a level, such as status:500, is searched as the attribute status)
bodymsg, message, text, log
trace_idtrace, traceid, trace.id
span_idspan, 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.

FormMeaning
field:value, field=valueequals (-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:>=nnumeric 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.

LimitValue
Query4,000 characters, 10 stages, one each of fields, sort, limit and stats
sort3 keys
fields30 names
json20 paths, 8 levels deep, each key at most 128 characters
Parsed fields32 per query; a parsed name cannot be a built-in field or start with attr. or res.
limit1 to 1,000
stats5 aggregates, 3 group fields, one bin()
stats rows500, or 5,000 when grouped by bin()
Chart series20 (the largest are kept, the chart says so)
in (...)50 values
Conditions100 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 tryWhat happensUse 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 fieldTwo searches, or | where service = "api" or env = "prod"
| top 5 by x, | timechart, | evalUnknown stage| stats count() by x | sort count desc | limit 5, bin(ts, 1m)
rate()Not an aggregatecount() grouped by bin(ts, 1m)
where a = bA field compared with another field is rejected: the right side is a string or numberCompare to a value
where severity > "warn"severity supports =, != and in onlyseverity:(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 stagesRejectedOne 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:

LogQLRuns as
{service="gateway"} |= "error"service:gateway "error"
{app="x"} | json | status >= 500service: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.