One filter syntax, used in the app, in the URL and in the API.
A semicolon is AND, a comma is OR, and AND binds tighter. A pipe inside a value is a list.
| country==SE | Equals |
| country!=SE | Does not equal |
| country==SE|NO|DK | Any of these |
| path^=/blog | Starts with |
| path$=.html | Ends with |
| path~=docs | Contains |
| path!~admin | Does not contain |
| path=~^/docs/[a-z]+$ | Regular expression |
| duration_ms>5000 | Greater than, for numeric fields |
| utm_source!= | Field is present |
| utm_source== | Field is empty |
Prefix with properties. for strings and num. for numbers. The distinction matters because numeric comparison on a string field is a silent wrong answer, so the two maps are kept apart.
properties.plan==pro;num.seats>=5 properties.plan==pro,properties.plan==business
Fields are checked against a closed allowlist. project_id and visitor_id are deliberately absent: a filterable project id is a cross-tenant read with extra steps, and filtering by a specific visitor turns an analytics tool into a surveillance tool.
Segments are also bounded at 50 conditions, 500 list values and 200-character expressions, so a pasted filter cannot become an expensive query by accident.
Unsegmented reports read pre-aggregated rollups. A segment on an arbitrary property cannot be answered from a rollup, so those queries read raw events and take longer on wide date ranges. The product tells you which store answered rather than leaving you to wonder why one view is slower.