ServerOps.ggbeta
GuidesLogs

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-bound

That'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)

ConcernWhere it lives
Filter "which events"Search bar (SOQL)
Aggregate "show me counts"Search bar (final group by)
Sort orderClick a column header in the results table
Result limit / paginationScroll, then "load more"
Time rangeDate picker above the search bar
Chart typeChart panel controls
Live tail toggleToggle 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

OperatorExampleNotes
==event == loginexact match, case-sensitive
:event:loginshorthand for ==, identical
!=dataset != debugnot equal

Numeric and ordinal comparison

OperatorExampleWorks on
> >= < <=bytes_raw > 1024numbers, dates, severity
between x and ybytes_raw between 1024 and 4096inclusive both ends
not between x and yts not between 2026-05-16 and 2026-05-17excludes 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.

OperatorExample
containsmessage contains "connection refused"
starts withactor starts with "admin:"
ends withsource ends with "-bot"

The right-hand side should be a quoted string (with double quotes) any time it has spaces or special characters.

Membership

OperatorExample
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

OperatorExampleNotes
existspayload.player_id existsthe field has a non-null value
missingpayload.player_id missingthe 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

OperatorExample
ANDseverity >= warn AND event == ping (also implicit between adjacent terms)
ORevent == login OR event == logout
NOTNOT 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:

  1. Field operators (==, !=, <, >=, contains, in, etc.)
  2. NOT / -
  3. AND / implicit-whitespace
  4. OR

Use parens any time you mix AND and OR.

Fields you can query

Every event has these built-in fields:

FieldTypeExamples
tsdatetimets > now-1h, ts >= 2026-05-16
severityordinalseverity >= warn
eventstringevent == weapon_pickup
actorstringactor starts with "user:"
sourcestringsource == fivem
datasetstringusually scoped via the URL, rarely queried
messagestringmessage contains "kicked"
bytes_rawnumberper-event raw size in bytes
event_ididentifierserver-assigned per event
ingested_atdatetimeserver 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 exists

Unknown 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 ago

Relative 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 source

Returns 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 no sum, 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 quote

Supported escape sequences inside quoted strings:

EscapeMeans
\"literal double quote
\\literal backslash
\nnewline
\rcarriage return
\ttab
\uXXXXUnicode 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 == 76561198012345678

Investigating 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_player

Auditing a moderator

actor starts with "admin:" AND ts > now-1d
actor == "admin:Mitch" group by event

Tracking uploads

event == upload group by actor
event == upload AND bytes_raw > 10485760     # > 10 MiB
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 actor

Events missing a field (data quality)

event == upload AND payload.size missing
event == player_join AND payload.steam_id missing

Time-bounded debugging

ts > 2026-05-16T10:00:00Z AND ts < 2026-05-16T11:00:00Z AND severity == error

Filtering out noise

severity >= info AND NOT event in (heartbeat, ping, keepalive)

Common mistakes

WroteWhat happensShould be
event = loginParse errorevent == login (double-equal) or event:login
event != "login" OR "logout"Unexpected behaviorevent not in (login, logout)
payload.player_id:1337*Empty result (1337* is the literal value)payload.player_id starts with "1337"
message:connection refusedParse error (whitespace)message contains "connection refused"
severity:warn,errorParse errorseverity in (warn, error)
ts > 1h agoParse errorts > now-1h
event:LOGIN matches nothingValues are case-sensitiveevent:login (or normalize at ingest)
event > 5Error: soql_invalid_operator_for_fieldevent is a string field; use == or in
org_id:other_orgError: soql_reserved_fieldTenant 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"
  }
}
CodeWhen you'll see it
soql_parse_errorThe query doesn't match the grammar.
soql_unknown_fieldReferenced a field that's not built-in and not a payload.* access.
soql_unknown_operatorUsed an operator that doesn't exist (e.g. ~, like).
soql_reserved_fieldTried to query org_id or project_id. These are bound to your token.
soql_invalid_operator_for_fieldE.g. numeric comparison on a string field, or text search on a numeric field.
soql_invalid_groupbygroup by referenced more than one field, or a reserved field.
soql_retention_exceededTime filter reaches further back than your plan's retention.
soql_quoting_errorUnterminated string literal or invalid escape.
soql_invalid_cursorPagination cursor doesn't match the current query, or is malformed.
soql_resource_limitQuery took too long or used too much memory. Tighten the time range or add filters.
soql_tail_aggregation_unsupportedgroup 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

LimitValue
Maximum query length4 KB
Maximum events per page (events query)100 (cursor-paginated past that)
Maximum group rows100, ordered by count descending
Maximum values in in (...) list100
Maximum parens nesting depth8
Maximum query execution time5 seconds
Concurrent queries platform-wide32

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/query for 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.

On this page