Skip to content

Trails Query

Run SQL over your organization's Trails events, asynchronously, and read or download the results.

Updated View as Markdown

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

  1. In the console, open Trails and select the Query tab.
  2. Write a query in the editor, or select one of the Sample queries.
  3. 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.
  4. Select Run, or press Ctrl+Enter (⌘ Enter on a Mac). The query page opens and shows the query’s status until it finishes.
  5. 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 30d

With --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).

  1. 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 the Location header:

    { "queryId": "0199a3e2-7c1d-7a4b-9e2f-1b3c4d5e6f70", "status": "queued" }
  2. Poll GetQuery until status is succeeded, failed or cancelled. While the query is queued or running, the response has a Retry-After header 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"
  3. 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 only to, it starts 7 days before to.
  • The window can be at most 90 days, and from must be before to.

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 in FROM, IN (SELECT …), EXISTS and as scalar values.
  • JOIN (inner, LEFT, RIGHT, FULL and CROSS, with ON or USING) between trails and your own CTEs.
  • DISTINCT, WHERE, GROUP BY (including GROUP BY ALL), HAVING, ORDER BY, LIMIT and OFFSET, and window functions with OVER and named WINDOWs.
  • SELECT * and t.*.
  • 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, floor and ceil.
  • 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 100

Refused 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 DESC

Events 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 DESC

Query 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; NaN and infinities are null. Dates and times are strings. JSON columns are objects.
  • limit is 1 to 1,000 rows (default 100). A page also stops at 4 MiB, and always has at least one row. Pass nextCursor as cursor for the next page; it’s null on the last page.
  • With Accept: text/csv or Accept: 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close