Trails Query runs a SQL SELECT over your organization’s events. Use it for questions the
lookup filters don’t answer, such as counts per day, per caller or per error.
Queries are asynchronous. Starting a query queues it and returns its ID. You then poll the query until it finishes and read its results, which are kept for 7 days.
| Operation | What it does | Needs |
|---|---|---|
| StartQuery | Checks the SQL and queues the query | audit.queries.run |
| GetQuery | Returns the query’s status and statistics | audit.queries.get for your own queries, audit.queries.list for anyone’s |
| GetQueryResults | Returns a page of rows, or downloads every row | audit.queries.get for your own queries, audit.queries.list for anyone’s |
| CancelQuery | Cancels a queued or running query | audit.queries.cancel, and permission to see the query |
| ListQueries | Lists queries, newest first | audit.queries.list for everyone’s; with only audit.queries.get, your own |
| GetQuerySchema | Lists the trails columns and the functions you can use |
Any of audit.queries.run, .get or .list |
StartQuery, GetQueryResults, CancelQuery and ListQueries are recorded in Trails.
GetQuery and GetQuerySchema are not.
Run a query
- In the console, open Trails and select the Query tab.
- Write a query in the editor, or select one of the Sample queries.
- Choose a Time range: Last hour, Last 24 hours, Last 7 days, Last 30 days, Last 90 days, or Custom range… with a From and To.
- Select Run, or press Ctrl+Enter (⌘ Enter on a Mac). The query page opens and shows the query’s status until it finishes.
- Page through the rows with Rows per page, Previous and Next, or download every row with CSV or JSON Lines.
To stop a queued or running query, select Cancel query on its page. History lists past queries: choose My queries or Everyone’s queries and a status, select a query to open it, or select Open in editor to run it again.
ezgh trails query run starts a query, waits for it, and prints its rows. Press Ctrl-C while
it waits to cancel the query.
ezgh trails query run "SELECT event_name, count() AS n FROM trails GROUP BY event_name ORDER BY n DESC" --from 30dWith --async, it prints the query ID and returns:
ezgh trails query run "SELECT event_time, principal_id FROM trails WHERE error_code = 'access_denied'" --async
ezgh trails query get 0199a3e2-7c1d-7a4b-9e2f-1b3c4d5e6f70 --wait
ezgh trails query results 0199a3e2-7c1d-7a4b-9e2f-1b3c4d5e6f70 --all
ezgh trails query results 0199a3e2-7c1d-7a4b-9e2f-1b3c4d5e6f70 --download csv > refused.csv| Command | Does |
|---|---|
ezgh trails query run "<SQL>" |
Runs a query. --from and --to set the window (default: the 7 days before --to, which defaults to now); --async returns at once; --limit sets rows per page read (1 to 1000). |
ezgh trails query get <query-id> |
Shows a query’s status. --wait waits for it to finish. |
ezgh trails query results <query-id> |
Prints a succeeded query’s rows (--limit, --all, --cursor), or streams every row with --download csv or --download ndjson. |
ezgh trails query cancel <query-id> |
Cancels a queued or running query. |
ezgh trails query list |
Lists queries, newest first. Filter with --status and --created-by (a principal ID or a user’s email address). |
ezgh trails query schema |
Lists the trails columns. With -o json, also the functions. |
Times are RFC 3339, a date, or a duration ago (2h, 7d).
-
Start the query with StartQuery:
curl -X POST -H "Authorization: Bearer $EZGH_API_KEY" \ -H "Content-Type: application/json" \ -d '{"sql": "SELECT event_name, count() AS n FROM trails GROUP BY event_name ORDER BY n DESC", "from": "2026-09-01T00:00:00Z", "to": "2026-09-29T00:00:00Z"}' \ "https://audit.ezghcloud.com/v1/organizations/$ORG_ID/queries"The response is
202 Accepted, with the query’s path in theLocationheader:{ "queryId": "0199a3e2-7c1d-7a4b-9e2f-1b3c4d5e6f70", "status": "queued" } -
Poll GetQuery until
statusissucceeded,failedorcancelled. While the query is queued or running, the response has aRetry-Afterheader with the seconds to wait before polling again.curl -H "Authorization: Bearer $EZGH_API_KEY" \ "https://audit.ezghcloud.com/v1/organizations/$ORG_ID/queries/$QUERY_ID" -
Read the rows with GetQueryResults:
curl -H "Authorization: Bearer $EZGH_API_KEY" \ "https://audit.ezghcloud.com/v1/organizations/$ORG_ID/queries/$QUERY_ID/results?limit=100"Or download every row as CSV (
Accept: text/csv) or newline-delimited JSON (Accept: application/x-ndjson):curl -H "Authorization: Bearer $EZGH_API_KEY" -H "Accept: text/csv" \ -o results.csv \ "https://audit.ezghcloud.com/v1/organizations/$ORG_ID/queries/$QUERY_ID/results"
To cancel, send DELETE to the query’s path (CancelQuery).
The time window
Every query runs over a window of events: from is inclusive and to is exclusive (RFC 3339).
- With neither, the window is the last 7 days.
- With only
from, it ends now. With onlyto, it starts 7 days beforeto. - The window can be at most 90 days, and
frommust be beforeto.
The window applies to every use of trails in the query. A WHERE condition on event_time
can narrow it but can’t widen it.
The trails table
Queries read one table, trails, with one row per event in your organization. Its columns
follow the event format. GetQuerySchema
returns each column’s exact type.
| Column | Type | Holds |
|---|---|---|
event_id |
UUID | eventId |
event_time |
Timestamp, UTC, milliseconds | eventTime |
event_type |
String | ApiCall, SignIn or ServiceEvent |
event_category |
String | eventCategory |
event_source |
String | eventSource, such as iam.ezghcloud.com |
event_name |
String | eventName, such as CreateApiKey |
read_only |
Boolean | readOnly |
project_id |
Nullable string | projectId, or NULL for organization-level events |
identity_type |
String | userIdentity.type |
principal_id |
Nullable string | userIdentity.principalId |
access_key_id |
Nullable string | userIdentity.accessKeyId |
user_identity |
JSON | userIdentity. Read its fields as subcolumns: user_identity.userName |
source_ip |
Nullable string | sourceIpAddress |
user_agent |
Nullable string | userAgent |
error_code |
Nullable string | errorCode, or NULL on success |
error_message |
Nullable string | errorMessage |
request_parameters |
JSON | requestParameters. Fields are subcolumns: request_parameters.name |
response_elements |
JSON | responseElements. Fields are subcolumns |
additional_event_data |
JSON | additionalEventData |
resources |
Array of (resource_name, type) |
resources. resources.resource_name is the array of names |
request_id |
Nullable string | requestId |
In the JSON columns, a key that was null or missing in the event reads as NULL.
SQL
A query is one SELECT statement of at most 16 KiB. You can use:
WITH(common table expressions),UNION ALL, and subqueries inFROM,IN (SELECT …),EXISTSand as scalar values.JOIN(inner,LEFT,RIGHT,FULLandCROSS, withONorUSING) betweentrailsand your own CTEs.DISTINCT,WHERE,GROUP BY(includingGROUP BY ALL),HAVING,ORDER BY,LIMITandOFFSET, and window functions withOVERand namedWINDOWs.SELECT *andt.*.- Arithmetic, comparison,
AND,OR,NOT,||,LIKE,ILIKE,IN (…),BETWEEN,IS [NOT] NULL,CASE,INTERVAL n SECOND|MINUTE|HOUR|DAY|WEEK|MONTH|QUARTER|YEAR,substring,trim,floorandceil. - Array and JSON access:
resources[1],user_identity.userName, and lambdas as the first argument of array functions:arrayExists(r -> r.type = 'Bot', resources). - The functions that GetQuerySchema lists: aggregates
(
count,countIf,uniq,sum,avg,min,max,argMax,quantile,topK,groupArray, …), window functions (row_number,rank,lagInFrame, …), and time, string, conditional, JSON, IP address, array, conversion and math functions. Function names are case-insensitive.
Write strings in single quotes. Names use letters, digits and _, up to 128 characters.
Comments are allowed.
These are refused with 400 invalid_query: more than one statement, any table other than
trails and your CTEs, table functions, functions not in the schema, CAST (use a conversion
function such as toString, toInt64 or toDateTime), SETTINGS, FORMAT, FINAL,
SAMPLE, PREWHERE, QUALIFY, SELECT … INTO, WITH RECURSIVE, UNION without ALL,
DISTINCT ON, ORDER BY ALL, WITH FILL, LIMIT … BY, NATURAL and GLOBAL joins,
* EXCEPT and other * modifiers, query parameters, and double-quoted strings.
topK, groupArray and groupUniqArray collect at most 1,000 values per group.
Examples
Who changed IAM, newest first:
SELECT
event_time,
toString(user_identity.userName) AS changed_by,
event_name,
arrayStringConcat(resources.resource_name, ', ') AS resources,
error_code
FROM trails
WHERE event_source = 'iam.ezghcloud.com' AND NOT read_only
ORDER BY event_time DESC
LIMIT 100Refused requests per API key per day:
SELECT
toDate(event_time) AS day,
access_key_id,
count() AS refused
FROM trails
WHERE error_code = 'access_denied' AND access_key_id IS NOT NULL
GROUP BY day, access_key_id
ORDER BY day DESC, refused DESCEvents that touched one resource:
SELECT event_time, event_name, principal_id, error_code
FROM trails
WHERE has(resources.resource_name, 'ezgh::org_k3f9a0x2m7qp:prj_g7h8i9j0k1l2')
ORDER BY event_time DESCQuery status
GetQuery returns the query:
{
"id": "0199a3e2-7c1d-7a4b-9e2f-1b3c4d5e6f70",
"sql": "SELECT event_name, count() AS n FROM trails GROUP BY event_name ORDER BY n DESC",
"status": "succeeded",
"from": "2026-09-01T00:00:00.000Z",
"to": "2026-09-29T00:00:00.000Z",
"error": null,
"stats": { "bytesScanned": "5242880", "rowsScanned": 48210, "elapsedMs": 312, "resultRows": 27 },
"resultRows": 27,
"truncated": false,
"cancelRequested": false,
"createdBy": "jtMJXwL0R8aB3cD4eF5gH6iJ",
"createdByIdentity": {
"type": "User",
"principalId": "jtMJXwL0R8aB3cD4eF5gH6iJ",
"userName": "ada@example.com",
"isRoot": false,
"accessKeyId": null,
"sessionContext": { "credential": "session", "sessionIssuedAt": "2026-09-29T08:00:00.000Z", "ssoProviderId": null, "mfaAuthenticated": false, "clientId": null },
"invokedBy": null
},
"createdAt": "2026-09-29T09:14:03.201Z",
"startedAt": "2026-09-29T09:14:03.410Z",
"finishedAt": "2026-09-29T09:14:03.839Z"
}| Field | Description |
|---|---|
status |
queued, running, succeeded, failed or cancelled |
queuePosition |
Present only while queued: the query’s place among your organization’s queued queries (1 is next) |
error |
{ "code", "message" } when the query failed, else null |
stats |
What a succeeded query read and returned: bytesScanned, rowsScanned, elapsedMs, resultRows. null otherwise. bytesScanned is a string of digits, since it can exceed what a JSON number holds exactly; the others are numbers |
resultRows |
Rows stored for the result |
truncated |
true if the result stopped at 100,000 rows |
cancelRequested |
true once a running query has been asked to stop |
createdBy, createdByIdentity |
Who started the query: their principal ID and their userIdentity |
createdAt, startedAt, finishedAt |
Times, RFC 3339 with milliseconds; startedAt and finishedAt are null until they happen |
A failed query’s error.code is one of:
| Code | Meaning |
|---|---|
invalid_query |
The query failed when it ran: an unknown column, a type mismatch, a failed conversion, division by zero |
query_too_large |
The query would read more than 500 million events. It fails before reading any data; narrow the window or add conditions on the time range |
too_much_data |
The query read more than the per-query limit |
timeout |
The query ran longer than 120 seconds |
memory_limit |
The query needed more than 4 GB of memory |
quota_exceeded |
Your organization’s hourly allowance is used up |
cancelled |
The query was stopped before it finished |
worker_lost |
The query stopped unexpectedly; run it again |
query_failed |
Any other failure |
Results
GetQueryResults returns a page of a succeeded query’s rows:
{
"columns": [
{ "name": "event_name", "type": "LowCardinality(String)" },
{ "name": "n", "type": "UInt64" }
],
"rows": [
["LookupEvents", "1840"],
["CreateApiKey", "12"]
],
"nextCursor": "eyJ2IjoxLCJvcmciOiIwMTk5YTNjMi01YjFlLTdkNDAtOWYzYS0yYzhlNmIxZDRmNzAifQ.c2ln",
"totalRows": 27,
"truncated": false
}- Each row is an array of values in the order of
columns. - 64-bit and wider integers are JSON strings, so no value is rounded (
count()is"1840"). Other numbers are JSON numbers;NaNand infinities arenull. Dates and times are strings. JSON columns are objects. limitis 1 to 1,000 rows (default 100). A page also stops at 4 MiB, and always has at least one row. PassnextCursorascursorfor the next page; it’snullon the last page.- With
Accept: text/csvorAccept: application/x-ndjson, the response streams every stored row as a file download. CSV has a header row, empty fields for nulls, and arrays and objects as JSON. NDJSON has one JSON object per row, keyed by column name.
Limits
| Limit | Value |
|---|---|
| Time window | Last 7 days by default; at most 90 days |
| SQL length | 16 KiB |
| Queued queries per organization | 5 |
| Running queries per organization | 2 at a time; the rest wait in the queue |
| Queries started per organization per hour | 200 |
| Data read per organization per hour | 500 GB |
| Events read per query | 500 million |
| Data read per query | 20 GB |
| Run time per query | 120 seconds |
| Memory per query | 4 GB |
| Result rows | 100,000; a bigger result is cut off and truncated is true |
| Result retention | 7 days after the query finishes |
| Result pages and downloads per organization | 20 per second |
| History page size | 1 to 100 queries (default 20) |
The hourly allowances reset on the hour (UTC).
Errors
| Status | Code | Cause |
|---|---|---|
400 |
invalid_request |
A body that isn’t { "sql", "from", "to" }, an empty sql, or a window that is invalid or longer than 90 days |
400 |
invalid_query |
The SQL isn’t allowed or doesn’t parse. The error has line and column |
400 |
invalid_cursor |
A cursor from another query or list, or edited |
403 |
access_denied |
The caller lacks the permission the operation needs |
404 |
not_found |
The query doesn’t exist in this organization |
409 |
query_not_finished |
Results were read while the query is queued or running. Retry after Retry-After |
409 |
query_failed |
Results were read for a failed query. reason holds its error.code |
409 |
query_cancelled |
Results were read for a cancelled query |
409 |
query_finished |
A cancel was sent for a query that has already finished |
410 |
results_expired |
The results are more than 7 days old. Run the query again |
429 |
too_many_queries |
Your organization already has 5 queued queries, or the queue is full. Retry after Retry-After |
429 |
quota_exceeded |
An hourly allowance is used up. The error’s quota.id is audit.queries.queriesPerHour or audit.queries.bytesReadPerHour. There is no Retry-After: try again after the hour |
429 |
too_many_requests |
Result pages or downloads were read too fast, or too many requests were sent. Retry after Retry-After |
503 |
unavailable |
A dependency is unavailable. Retry after Retry-After |
A 400 invalid_query looks like this:
{
"error": {
"code": "invalid_query",
"message": "CAST isn't supported; use a conversion function such as toString, toInt64 or toDateTime",
"line": 1,
"column": 13
}
}CancelQuery answers 200 with the query when a queued query is cancelled, and 202 with the
query when a running query has been asked to stop; poll it until its status is cancelled.