Sequence queries (SeQL)
Sequence Queries (SeQL) is a small language for questions that have an ordering in them. Most analytics answers “how many”. SeQL answers: who logged in, opened a chest, then purchased, in that order, inside one session? What did people do between opening the chest and purchasing? Which players reached step two and stopped? It is the language behind funnels, and it reaches well past them.
Availability. This is an early feature. You reach it from the funnel chart editor by turning on the sequence-query toggle, and it runs its full feature set on BigQuery today. There is no dedicated editor yet: you write the query as text and see errors when it runs.
A query is a pipeline
A query is a list of statements, each ending in a semicolon. Each statement narrows or annotates a stream of events, so you can read a query top to bottom like a spreadsheet gaining columns:
domain login, open_chest, purchase;
split by login;
funnel login -> open_chest -> purchase;
Read it as: consider these three event types, cut each player’s stream into sessions at every login, then find the funnel inside each session.
There is no SELECT. A query produces an annotated stream of events, and the chart decides what shape to read it at, so the same query can drive a funnel chart and a region view without being rewritten.
Player journeys, beyond the funnel
A funnel is one slice of a player’s journey: it tells you where people stop, not what they did instead. Standard funnel tooling discards everything that is not a step. SeQL keeps it, and names it: the events between step one and step two are a first-class object you can count, filter, and aggregate. That makes questions expressible that a funnel builder cannot ask:
- How many events happen between the chest and the purchase, and how long do they take?
- What happens after the funnel completes? On a typical game session this is the largest bucket of all, and no funnel chart shows it.
- Of the players who stalled, what did they do in the gap?
Two more properties matter:
- Sessionization is part of the query, not a preprocessing step.
split by loginorsplit by gap > 30mcuts the stream into sessions inline, and a compound boundary (split by login or gap > 30m) stays accurate even when event tracking drops events. - Point-in-time user state joins per event.
user.countryon any row resolves to that player’s country on the date of that event, not today’s value. This is the same as-of correctness the rest of Asemic provides, now available inside a sequence.
The mental model
Four ideas carry the whole language. A reader who has them can read any query.
One merged stream
Events from different tables are merged into a single stream, ordered by time, per player. A login row and a purchase row sit side by side. Each row carries the columns of its own event type and null for the others.
The domain is an aperture, not a row filter
domain login, purchase; means “for the rest of this query, these are the events that exist.” Everything downstream, including what counts as “immediately followed by”, is relative to that aperture.
This is the idea most worth getting right, because it is where intuition goes wrong. In domain login, purchase; match login >> purchase;, the >> means “the very next event in the domain”. If a chest opening happened in between, it does not break the match, because chest openings are not in the domain. Widen the domain to include them and the same query stops matching.
The idiom that follows: open wide to sessionize, narrow to analyse. Use every event to find session boundaries accurately, then narrow to the events the pattern is about.
A sequence is a subsequence of one player’s stream
split by cuts each player’s stream into subsequences. Everything after it, patterns and aggregates, operates inside one subsequence at a time. Without a split by, the whole of a player’s history is one sequence.
Every clause attaches or drops a column
set attaches a value. match attaches labels. filter drops rows. Nothing rewrites the stream. Picture a spreadsheet gaining columns as you read down the query.
Writing a query: the clauses
Statements are separated by semicolons. Comments run to end of line with // or --. Whitespace and newlines are not significant.
domain, the aperture
domain login, purchase; -- the events in play
domain purchase where amount > 10 as big_purchase; -- filter, and name the result
domain open_chest rename level as chest_level; -- resolve a name collision
domain add transaction; -- widen again, later in the pipeline
domain remove battle; -- narrow
wheretakes any expression over that event’s columns.asnames the filtered result, and that name is what the pattern refers to.renameexists because two event types can use one column name for different things. Renaming splits a false unity, or creates a real one (a checkpoint’sstagerenamed tolevelso it lines up with another event’slevel).domain addre-admits events after a pattern has run. The re-admitted events cannot change where a session boundary fell or which rows matched; they join as passengers. This is how you ask “what else happened in the gap” without disturbing the match.
Referring to a column: purchase.amount is that event’s column. A bare amount resolves across every event in the domain that carries a column of that name, and it is an error if their types disagree.
split by, sessionization
split by login; -- a new sequence at every login
split by gap > 30m; -- a new sequence after 30 minutes of inactivity
split by login or gap > 30m; -- either, which is the robust form
The boundary is a predicate, not an event name, which is what allows the compound form. Prefer the compound form: a login-only boundary loses sessions when a login event is not tracked, and a gap-only boundary merges two sessions that happen back to back.
split by can be nested: a second split by cuts within the blocks the first one produced.
Duration literals are s, m (also spelled min), h, d, w, for example 30m, 24h, 7d.
match, the pattern
match login >> purchase; -- purchase is the very next event in the domain
match login -> purchase; -- purchase happens eventually
match login >> (battle | quest) >> purchase; -- either event in the middle
match login >> battle{2,} >> purchase; -- two or more battles
match login >> ^purchase >> battle; -- something that is not a purchase
match start >> login >> purchase; -- anchored at the start of the sequence
match login >> purchase within 1h; -- and all of it inside an hour
| Syntax | Meaning |
|---|---|
>> | immediately followed by, in the domain |
-> | followed eventually |
| | alternation, either branch |
* + ? | zero or more, one or more, optional |
{n} {n,} {n,m} | exact, at least, between |
trailing ? on a quantifier | reluctant, take the shortest match |
^name | any event that is not this one |
() | any single event |
start end | anchors, the ends of the sequence |
within <duration> | a conversion window, measured from the first event of the match |
Two things worth knowing:
- A bare
*is reluctant:a >> * >> bfinds the firstbaftera, not the last. That is what people mean by “eventually”, and->is shorthand for exactly this. withintruncates rather than discards. If step two lands inside the window and step three does not, the player still counts at step two. A conversion window shortens a funnel, it does not delete it.
funnel, the shorthand
funnel login -> open_chest -> purchase;
funnel login >> open_chest -> purchase within 24h;
A funnel is a pattern where each step is optional but only reachable through the one before it, so a player who reaches step two and stops still counts at step two. Written by hand it is unwieldy; funnel is the readable form.
Strictness is per step. >> between two steps demands they be adjacent in the domain; -> allows anything in between. Mix them freely. Prefer ->: it is what people mean by a funnel, and >> is the specialist case.
Common mistake: use
funnel, notmatch, for funnels. A plainmatchis all-or-nothing. A player who logs in and stops is not counted at step one at all, so every step reports the same number. If your funnel looks flat, this is almost always why.
filter
filter matched; -- keep only sequences where the pattern matched
filter amount > 100; -- keep only rows above the threshold
The grain is inferred from the expression. A predicate about the whole sequence (like matched) drops whole sequences; a predicate about one row drops rows.
set, computed values
set row.is_big = amount > 100; -- one value per event
set SEQ.spend = sum(purchase.amount); -- one value per sequence
set SEQ.started = min(asemic_event_timestamp);
set row.elapsed = seconds_between(asemic_event_timestamp, started);
set purchase.first = min(asemic_event_timestamp); -- over one step's rows only
set stream.total = count(); -- over the player's whole stream
The prefix is the grain the aggregate runs over, not where the value lives. set SEQ.spend = sum(...) sums over the current sequence and makes that one total readable from every row of it. Scopes, from finest to coarsest: row, an element name from the pattern, SEQ, stream.
Reading a value back uses its bare name. After set SEQ.started = ..., later statements write started, not SEQ.started; using the prefixed form in a read position is an error.
The functions are count, count_distinct, sum, min, max, avg, first_value, last_value, last, coalesce, if, change, seconds_between, infix. An unrecognised function is a compile error, so this list is the whole list.
combine
combine total = sum(spend);
Pops back out one level of split by, folding values up from the inner sequences to the outer one. combine; on its own is a bare pop.
Reserved names you can read
| Name | Meaning |
|---|---|
gap | seconds since the previous event in the domain |
matched | whether the pattern matched in this sequence |
region | the row’s region label, as a string (see below) |
prefix, suffix | true when the row is before the first step or after the last |
infix(a, b) | true when the row lies between step a and step b |
infix_1, infix_2 | the same, numbered by position in the pattern |
user.<property> | the player’s value for that property, on the date of this event |
asemic_event_timestamp | the event’s timestamp, whatever the underlying column is called |
asemic_entity_id | the player id |
asemic_date | the event’s date |
set row.country = user.country gives the country recorded for that player on the day of that event, so a player who changed country mid-sequence shows both. This is the point-in-time correctness the rest of Asemic provides, now available per event.
The region model
Every event in a sequence belongs to exactly one region. For a pattern with steps a, b, c:
| Region | What it holds |
|---|---|
Prefix | events before the first step |
a, b, c | the events that are the steps |
infix(a, b) | events between step a and step b |
infix(b, c) | events between step b and step c |
Suffix | events after the last step |
There is one rule, not five: a region is bounded by the nearest step before it and the nearest step after it, and Prefix and Suffix are the cases where one of those bounds is missing.
Regions are addressable, which is what makes them useful:
filter infix(open_chest, purchase); -- only the events in that gap
set infix_1.waited = count(); -- how many events in the first gap
set infix_2.spend = sum(purchase.amount); -- what they spent in the second
filter region != 'Prefix'; -- drop the run-up
infix_1 is numbered by position in the pattern, so it means the same gap for every player. That is what makes it aggregatable, and it is the number a drop-off view reports.
Warehouse support
Plain step patterns run on any supported warehouse: a pattern that is only >> between named steps compiles to standard window functions.
Everything richer currently runs on BigQuery: quantifiers, alternation, negation, the * wildcard, ->, and therefore funnel. A query that needs a feature the connected warehouse does not support is refused with a clear message, never answered with a narrower result, so you are not handed a wrong number.
Common mistakes
The two mistakes almost everyone makes once:
matchwhere you wantedfunnel. A plainmatchis all-or-nothing, so a funnel built from it reports the same count at every step. Usefunnelfor funnels.- Forgetting
>>is relative to the domain.>>means “the very next event in the domain”, not “the very next event”. If a match is not firing where you expect, check whether an event you left out of the domain is what you actually wanted between the steps, or whether an event you left in is breaking adjacency.
Examples
A session funnel
domain login, open_chest, purchase;
split by login;
funnel login -> open_chest -> purchase;
A funnel with a conversion window
domain login, open_chest, purchase;
split by login;
funnel login -> open_chest -> purchase within 24h;
What happens in the gap (open wide to sessionize, narrow to analyse, then widen again)
domain login, purchase;
split by login or gap > 30m;
match login >> purchase;
domain add open_chest, battle;
set infix_1.events_between = count();
Time to each step
domain login, open_chest, purchase;
split by login;
funnel login -> open_chest -> purchase;
set SEQ.started = min(asemic_event_timestamp);
set row.elapsed = seconds_between(asemic_event_timestamp, started);
Segmenting a funnel by point-in-time state
domain login, open_chest, purchase;
split by login;
funnel login -> open_chest -> purchase;
set row.country = user.country;
Sessions where the player stalled
domain login, open_chest, purchase;
split by login;
funnel login -> open_chest -> purchase;
filter infix(open_chest, purchase);