SOQL - ServerOps Query Language
Search and aggregate your logs with a small, readable query language built for game-server operators. No SQL required.
SOQL - ServerOps Query Language
SOQL is the query language for searching log events on ServerOps. It powers the search bar in the Logs dashboard and the POST /v1/query and POST /v1/query/tail endpoints.
The whole language is twelve operators plus an optional group by clause. A non-programmer should be able to learn it in five minutes. If you already know any search syntax (Splunk, Lucene, GitHub Issues), the colon-shorthand will feel familiar.
Quick start
Type these into the search bar on /dashboard/logs, or send them in the q body field of the API. The time range comes from the date picker above the search bar (or the ts field, see Time filters).
event == weapon_pickup # one filter
player_id:1337 AND event:weapon_pickup # shorthand: is ==
severity >= warn # severity comparison
actor starts with "admin:" # prefix match
event in (login, logout, kick) # membership
payload.player_id exists # non-null check
message contains "connection refused" # substring search
event == auth_fail group by actor # top actors by count
NOT dataset == debug # negation
(event == login OR event == logout) AND source == fivem # grouping
ts > now-1h AND severity == error # time-boundThat's the whole language: twelve operators, optional grouping, parens for combining. Sort order, limit, time range, and chart type are dashboard controls - not part of the query.
What the search bar does (and what the dashboard does)
| Concern | Where it lives |
|---|---|
| Filter "which events" | Search bar (SOQL) |
| Aggregate "show me counts" | Search bar (final group by) |
| Sort order | Click a column header in the results table |
| Result limit / pagination | Scroll, then "load more" |
| Time range | Date picker above the search bar |
| Chart type | Chart panel controls |
| Live tail toggle | Toggle button next to the search bar |
This split is intentional. The language stays small because the dashboard handles everything that isn't filtering.
Operators
Equality
| Operator | Example | Notes |
|---|---|---|
== | event == login | exact match, case-sensitive |
: | event:login | shorthand for ==, identical |
!= | dataset != debug | not equal |
Numeric and ordinal comparison
| Operator | Example | Works on |
|---|---|---|
> >= < <= | bytes_raw > 1024 | numbers, dates, severity |
between x and y | bytes_raw between 1024 and 4096 | inclusive both ends |
not between x and y | ts not between 2026-05-16 and 2026-05-17 | excludes the range |
Severity is ordinal: trace < debug < info < warn < error < fatal. So severity >= warn matches warn, error, and fatal.
Text matching
All three are case-insensitive.
| Operator | Example |
|---|---|
contains | message contains "connection refused" |
starts with | actor starts with "admin:" |
ends with | source ends with "-bot" |
The right-hand side should be a quoted string (with double quotes) any time it has spaces or special characters.
Membership
| Operator | Example |
|---|---|
in (a, b, c) | event in (login, logout, kick) |
not in (a, b, c) | event not in (debug, trace) |
Lists hold up to 100 values.
Existence
| Operator | Example | Notes |
|---|---|---|
exists | payload.player_id exists | the field has a non-null value |
missing | payload.player_id missing | the field is null or unset |
Most useful for payload.* fields, where keys are optional per event. For canonical fields (event, actor, etc.) the value is always present at least as an empty string, so event exists is almost always true.
Combining
| Operator | Example |
|---|---|
AND | severity >= warn AND event == ping (also implicit between adjacent terms) |
OR | event == login OR event == logout |
NOT | NOT event == ping (also: -event:ping for a single term) |
(, ) | (a OR b) AND c |
Keywords are case-insensitive (and, And, AND all work). Two adjacent terms without an operator are implicit-AND: severity:error event:ping means severity == error AND event == ping.
Operator precedence, from highest to lowest:
- Field operators (
==,!=,<,>=,contains,in, etc.) NOT/-AND/ implicit-whitespaceOR
Use parens any time you mix AND and OR.
Fields you can query
Every event has these built-in fields:
| Field | Type | Examples |
|---|---|---|
ts | datetime | ts > now-1h, ts >= 2026-05-16 |
severity | ordinal | severity >= warn |
event | string | event == weapon_pickup |
actor | string | actor starts with "user:" |
source | string | source == fivem |
dataset | string | usually scoped via the URL, rarely queried |
message | string | message contains "kicked" |
bytes_raw | number | per-event raw size in bytes |
event_id | identifier | server-assigned per event |
ingested_at | datetime | server clock at API receive |
Custom fields (payload.*)
Anything else your application included in the event is queryable via payload.<key>:
payload.player_id == 1337
payload.ip starts with "1.2.3."
payload.steam_id existsUnknown payload keys silently return zero results rather than erroring. The dashboard suggests keys it has seen recently in your dataset to help you avoid typos.
Bare terms
A value with no field name searches the message field as a substring:
"connection refused" # same as: message contains "connection refused"
kicked # same as: message contains "kicked"Time filters
The date picker above the search bar is the easiest way to bound time. For URL-shareable queries or fine-grained filters, the search bar accepts:
ts > 2026-05-16T10:00:00Z # RFC3339
ts >= 2026-05-16 # date-only, start of day UTC
ts > 1747397700 # Unix seconds
ts > 1747397700123 # Unix milliseconds
ts > now # request time
ts > now-1h # 1 hour ago
ts > now-30m # 30 minutes ago
ts > now-7d # 7 days ago
ts > now-7d AND ts < now-1d # between 1 and 7 days agoRelative suffixes are s, m, h, d.
Default time window: queries without a ts filter look back over the last 24 hours.
Tier limit: your plan's retention period is the maximum lookback. A query reaching further back returns soql_retention_exceeded with the actual cap.
Aggregations: group by
A single optional clause at the end of any query:
event == auth_fail group by actor
severity >= warn group by event
event == upload group by sourceReturns up to 100 group rows ordered by count descending. Each row is a {value, count} pair.
- Only one field can be grouped per query.
- The implicit aggregation is
count()- there's nosum,avg, etc. For richer breakdowns, use a chart panel in the dashboard.
Quoting strings
Wrap any value that has spaces, colons, parens, or quote characters in double quotes:
actor == "user:42 (admin)" # space and paren
message contains "say: hello" # colon
event == "weird value with \" quote" # escape inner double quoteSupported escape sequences inside quoted strings:
| Escape | Means |
|---|---|
\" | literal double quote |
\\ | literal backslash |
\n | newline |
\r | carriage return |
\t | tab |
\uXXXX | Unicode codepoint (4 hex digits) |
Single quotes are not accepted as string delimiters. Use double quotes.
Field names are matched case-insensitively (Player_ID and player_id both work). Values are case-sensitive.
Examples by use case
Finding a player's actions
player_id:1337
payload.steam_id == 76561198012345678Investigating a security incident
severity >= warn
event in (auth_fail, account_locked, suspicious_login) AND ts > now-2h
actor == "admin:Mitch" AND event == player_ban group by payload.target_playerAuditing a moderator
actor starts with "admin:" AND ts > now-1d
actor == "admin:Mitch" group by eventTracking uploads
event == upload group by actor
event == upload AND bytes_raw > 10485760 # > 10 MiBChat keyword search
event == chat AND message contains "raid"
event == chat AND actor == "user:1337"Top noisy events (spotting abuse)
ts > now-1h group by event
ts > now-1h AND severity == error group by actorEvents missing a field (data quality)
event == upload AND payload.size missing
event == player_join AND payload.steam_id missingTime-bounded debugging
ts > 2026-05-16T10:00:00Z AND ts < 2026-05-16T11:00:00Z AND severity == errorFiltering out noise
severity >= info AND NOT event in (heartbeat, ping, keepalive)Common mistakes
| Wrote | What happens | Should be |
|---|---|---|
event = login | Parse error | event == login (double-equal) or event:login |
event != "login" OR "logout" | Unexpected behavior | event not in (login, logout) |
payload.player_id:1337* | Empty result (1337* is the literal value) | payload.player_id starts with "1337" |
message:connection refused | Parse error (whitespace) | message contains "connection refused" |
severity:warn,error | Parse error | severity in (warn, error) |
ts > 1h ago | Parse error | ts > now-1h |
event:LOGIN matches nothing | Values are case-sensitive | event:login (or normalize at ingest) |
event > 5 | Error: soql_invalid_operator_for_field | event is a string field; use == or in |
org_id:other_org | Error: soql_reserved_field | Tenant scope is fixed by your token |
Errors you might see
Every error response has the shape:
{
"error": {
"code": "soql_parse_error",
"message": "unexpected token 'WHERE' at position 14; expected a field name or boolean operator",
"position": 14,
"near": "event:foo WHERE bar"
}
}| Code | When you'll see it |
|---|---|
soql_parse_error | The query doesn't match the grammar. |
soql_unknown_field | Referenced a field that's not built-in and not a payload.* access. |
soql_unknown_operator | Used an operator that doesn't exist (e.g. ~, like). |
soql_reserved_field | Tried to query org_id or project_id. These are bound to your token. |
soql_invalid_operator_for_field | E.g. numeric comparison on a string field, or text search on a numeric field. |
soql_invalid_groupby | group by referenced more than one field, or a reserved field. |
soql_retention_exceeded | Time filter reaches further back than your plan's retention. |
soql_quoting_error | Unterminated string literal or invalid escape. |
soql_invalid_cursor | Pagination cursor doesn't match the current query, or is malformed. |
soql_resource_limit | Query took too long or used too much memory. Tighten the time range or add filters. |
soql_tail_aggregation_unsupported | group by not allowed on the live-tail endpoint. |
The position field is a 0-indexed byte offset into your query string. The near field is a short slice of source context. Both help the dashboard underline the offending token in the search bar.
Limits
| Limit | Value |
|---|---|
| Maximum query length | 4 KB |
| Maximum events per page (events query) | 100 (cursor-paginated past that) |
| Maximum group rows | 100, ordered by count descending |
Maximum values in in (...) list | 100 |
| Maximum parens nesting depth | 8 |
| Maximum query execution time | 5 seconds |
| Concurrent queries platform-wide | 32 |
Hitting any of these returns a clean error rather than a silent truncation.
Using SOQL from the API
The same language works in POST /v1/query and POST /v1/query/tail:
curl -X POST https://api.serverops.gg/v1/query \
-H "Authorization: Bearer so_live_..." \
-H "Content-Type: application/json" \
-d '{"q":"event:auth_fail group by actor"}'The endpoint returns an events page ({events, next_cursor, has_more}) for filter-only queries, or a groups page ({field, groups}) for queries that end in group by. The shape is distinct so your client can switch renderers by checking which top-level key is present.
Live tail (/v1/query/tail) opens a Server-Sent Events stream of new events matching the same filter. See the API reference for the SSE wire format.
Live tail availability:
- Logs Pro plan or higher. Hobby-tier requests return
tier_upgrade_required. - Up to 3 concurrent tail sessions per token. Opening a 4th returns
tail_session_limit_exceeded. - Aggregation queries (
group by) cannot be tailed; use/v1/queryfor one-shot results.
Not in SOQL (intentionally)
To keep the language learnable, the following are NOT supported:
- Pipe stages (
| stats,| sort,| head,| dedup, etc.). Use the dashboard's column / time / chart controls instead. - Regex match. Use
contains/starts with/ends with. - Wildcards. Same: use the text operators.
- Computed fields. Add them at ingest time.
- Joins or sub-queries.
- Custom aggregations (sum, avg, percentiles). Use a dashboard chart panel.
These may be added in a future version if there's clear demand. Today the language stays small on purpose - a server owner should be able to type a useful query without referring back to this guide.