Path Patterns

A path pattern says "this happened, then that". It is the shared language behind Step Matrix's path_pattern, the matches_pattern metric, and the start_anchor/end_anchor anchors of truncate_paths. One parser, one set of rules, one set of error messages — whatever accepts a pattern accepts all of the syntax on this page.

The design borrows Python's regular expressions with one substitution: a token is an event, not a character. If you know regex, the only things you have to unlearn are on this page, and there are two of them.

The tokens

TokenMatchesExample
cartthat eventadd_to_cart->cart
[a|b|c]one event, any of those listed[search|filter_results]->add_to_cart
[^a]one event, anything but aadd_to_cart->[^cart]
[^a|b]one event, none of those listedcatalog->[^cart|purchase]
.one event, any of them.->product_view
.*any run of events, including nonecatalog->.*->purchase
[^a]*a run containing no aadd_to_cart->[^support_chat]*->purchase
[^a|b]*a run containing none of those listedadd_to_cart->[^support_chat|error_page]*->purchase
[a|b]*a run containing nothing but those listedcart->[shipping_details|payment_details]*->purchase
path_start, path_enda path's own boundariespath_start->home

Tokens are separated by ->. Tokens not separated by .* must be strictly adjacent in the path: add_to_cart->cart means cart came immediately next, while add_to_cart->.*->cart means it came eventually. A pattern matches anywhere in a path — there is no implicit anchoring at either end.

Every token except a gap occupies exactly one position. That matters for at= in an anchor spec, for occurrence=, and for Step Matrix centring, all of which count positions:

[search|catalog] -> .* -> add_to_cart -> [^cart]
    position 0      —      position 1     position 2

A class is one position no matter how many events it lists. A gap is not a position at all — quantifying a class with * turns it from "one event like this" into "a run of events like this", and runs take no ordinal.

Two things regex users have to unlearn

^ and $ are not anchors. A path already has names for its own boundaries — path_start and path_end are real events that appear in patterns like any other token, and the rest of the library uses them everywhere. So negation is written inside the brackets, exactly where regex puts it in a character class: [^cart], never ^[cart]. Writing ^cart or cart$ is an error that points you at the sentinels.

Members are separated by |. A regex character class is written by juxtaposition ([ab]) because a character is one symbol wide. Event names are not, so [add_to_cart|cart] needs the separator. Inside brackets | means "any of these"; it does not work outside them, because a pattern cannot branch into alternative sequences.

Boundaries are not events

. and [^...] never match path_start or path_end, mirroring regex, where . does not match a string boundary because a boundary is not a character. A sentinel takes part only when you name it: [path_end|error_page] matches both, [^cart] matches neither.

This is what makes the following work:

# a product_view that was NOT the first event of the path
stream.step_matrix(path_pattern=".->product_view")

. requires a real event before the product_view, and the path_start row does not qualify. Without the rule, catalog->[^cart] would be true for any path ending in catalog — the path_end row is, after all, "not cart" — which is the opposite of what the pattern says.

The rule falls out of the definition rather than being special-cased, so [^path_start] is legal and means exactly the same as ..

Restricting what lies between

Quantify a class with * and it stops being a position: it becomes a gap that says what may appear in the run between the two anchors around it.

# bought without ever contacting support
stream.filter_paths({"op": "=", "metric": "matches_pattern", "value": True,
                     "metric_args": {"pattern": "add_to_cart->[^support_chat]*->purchase"}})

# went from cart to purchase through checkout steps only, no wandering
stream.step_matrix(path_pattern="cart->[shipping_details|payment_details]*->purchase")

.* is the unrestricted case of the same construct — . repeated — which is why [^X]* composes so plainly. It is also why negation is written inside the brackets: with ^[X]* it would be unreadable whether the repetition sits inside the negation or outside it.

A gap matches the empty run, so A->[^X]*->B is true for an adjacent A->B. That is the same rule .* has always followed.

A negated gap is a blacklist and a positive one a whitelist, and the choice matters as your event vocabulary grows: [^support_chat|error_page]* keeps meaning what it meant when a new event appears, while [shipping_details|payment_details]* silently gets stricter. Pick by which list you can keep complete.

A restricted gap needs an anchor on both sides. At either end of a pattern its outer side is unpinned, and since the empty run always satisfies it, it would silently mean nothing:

stream.step_matrix(path_pattern="[^purchase]*->cart")
# PatternSyntaxError: '[^purchase]*' at the start of '[^purchase]*->cart' has
# nothing on its outer side to bound it, so it would match the empty run and
# mean nothing. Anchor it — e.g. 'path_start->[^purchase]*->cart'.

Naming path_start or path_end is usually what was meant: path_start->[^purchase]*->path_end selects paths that never purchased.

Examples

One position, negated or alternated:

PatternQuestion it answers
path_start->[^home]paths that did not start on the home page
.->product_viewproduct views that were not the path's first event
add_to_cart->[^cart]added to cart, then went somewhere other than the cart
[search|filter_results|compare]->add_to_cartadded to cart straight after any research action
[payment_error|checkout_bug]->path_endpaths that ended right after a checkout failure
catalog->.->purchaseexactly one event between the catalog and the purchase

A whole run, restricted:

PatternQuestion it answers
add_to_cart->[^support_chat]*->purchasebought without contacting support
add_to_cart->[^support_chat|error_page]*->purchasebought without hitting either support or an error
cart->[shipping_details|payment_details]*->purchasechecked out without wandering off
path_start->[^purchase]*->path_endnever purchased
home->[^error_page]*->purchaseconverted along a clean path

Combined with anchors and centring:

# centre a Step Matrix on either failure, without merging the two events first
stream.step_matrix(path_pattern="[payment_error|checkout_bug]")

# a window opening at the first search-or-catalog that leads to a purchase
stream.truncate_paths(
    start_anchor={"pattern": "[search|catalog]->.*->purchase", "at": 0},
    end_anchor="purchase",
)

# a per-path 0/1 metric
stream.get_metrics([
    {"metric": "matches_pattern",
     "metric_args": {"pattern": "promo_page->.*->[purchase|cart]"}},
])

Centring on a class behaves exactly as centring on an event: the anchor resolves to a position, and a class fills one. The single visible difference is that column 0 of the resulting matrix holds a distribution over the class members instead of one event at 1.0.

The workaround this replaces. Before classes existed, "any of these events" meant merging them with rename_events — damaging the stream to ask one question. [a|b] gives the same answer with the data left alone.

What is not supported

Negation and alternation work exactly where their scope is bounded — one position, or a run pinned by an anchor on each side. Anything wider is rejected by the parser rather than half-supported:

RejectedWhy
[^cart->purchase]a class describes one event, not a sequence
cart->purchase|catalog->add_to_carta pattern cannot list alternative sequences
(cart|purchase)round brackets are not part of the syntax; write [cart|purchase]
^cart, cart$not anchors — write path_start->cart, cart->path_end
[cart|purchase]+, [^cart]?only * is supported after a class
[^cart]*->purchasea restricted gap at either end has nothing bounding its outer side
cart->.*->[^purchase]*->catalogtwo gaps in a row
[], [^], [^[a|b]]empty or nested classes

Negating a whole sequence has no left boundary: the complement of an unanchored sequence matches almost every path, and computing it needs a different kind of engine than the relational one underneath. A restricted gap escapes this precisely because both of its ends are pinned. To exclude paths containing a sequence, use a Filter Paths condition with {"op": "NOT", "args": [...]} instead — that is a statement about whole paths, which is a different question from what a pattern position asks.

Mistakes the parser catches

Every name in a pattern is checked against the events actually present in the eventstream, including names inside a class. This matters more for a class than for a bare token: a typo in purchse matches nothing and gives you an empty result, but a typo in [^purchse] excludes nothing and gives you an always-true position — a wrong answer that looks like a healthy one.

stream.step_matrix(path_pattern="[^Purchse]->cart")
# InvalidParameterError: Invalid value 'Purchse' for parameter 'path_pattern'.
# Allowed values: ['account_page', 'add_to_cart', ..., 'purchase', ...]

Two more:

  • A pattern with nothing to look for — every position negated or a wildcard, like [^cart]->.*->. — is legal but almost always true, and warns. A positive class counts as something to look for: [cart|purchase] is as specific as cart, just about two events.
  • An event named like a class. A token is a class only when it is entirely bracketed, so an event called checkout [beta] is unaffected. Only an event named exactly [checkout], or one containing | that lands inside brackets, collides — and rather than inventing an escape syntax for something this rare, the pattern is rejected with an explanation. Rename the event with rename_events to use it in a pattern.

Which match, when there are several

A pattern usually matches a path in more than one way, and occurrence picks between them. It is easiest to reason about by its result: "first" puts every token as early as that token can be in any valid match, "last" as late as it can be.

The word "valid" is load-bearing. "last" is not "the last occurrence of the event" — an occurrence taking part in no complete match is not a candidate at all. On catalog, cart, purchase, cart, the cart token of catalog->.*->cart->.*->purchase anchors on the second event, not the fourth, because no purchase follows the fourth.

Classes change nothing here. [a|b] is a token like any other, and the same definition decides which of its matches gets the anchor.

A restricted gap does not change the definition either, but it does change which occurrences qualify — and not always in the direction you would guess. On the path A, X, A, D:

Patternoccurrence="first" anchors A at
A->.*->Dthe first A — both reach D
A->[^X]*->Dthe second A — the first one's run to D crosses the X

Nothing special is happening: "as early as it can be in any valid match" is the rule in both rows, and under the restricted gap the first A takes part in no valid match at all.

occurrence="all"

"all" picks nothing — it returns every position each token can occupy in some valid match. On A, B, A, B the A token of A->.*->B resolves to both As, where "first" would give one and "last" the other.

Because it returns several positions per path, the rows no longer describe one match, and anything needing a single position rejects it. truncate_paths does: a window bound has to be one place to cut. add_events is what it is for.

Turning a position into an event

A pattern can describe a position that no event name can — "the cart that checkout actually followed, not the one abandoned for more browsing". But only an event name can be centred on by Step Matrix, counted by a funnel, or filtered on. add_events(anchor=...) bridges the two: it inserts a named event at the position a pattern resolves to.

# the cart that led to checkout with no cart in between
stream = stream.add_events(
    "checkout_cart",
    anchor={"pattern": "cart->[^cart]*->shipping_details", "at": "start"},
)

# now the position is addressable like any other event
stream.step_matrix(path_pattern="checkout_cart")
stream.get_conversion_rate(start_anchor="checkout_cart", end_anchor="purchase")

The anchor takes the same spec as truncate_paths's start_anchorpattern, at, occurrence, offset, offset_side — with one anchor per call rather than a list. By default it marks one position per path; occurrence="all" marks every one, which is how you count attempts rather than users:

stream.add_events(
    "checkout_attempt",
    anchor={"pattern": "cart->[^cart]*->shipping_details",
            "at": "start", "occurrence": "all"},
)

The new event shares its anchor's timestamp and sorts immediately before it, so a matrix centred on the marker shows the anchor event itself at column 1 and the true run-up in the negative columns. An offset moves the marker off the match — {"pattern": "purchase", "offset": -3} marks three events before each purchase — and clamps to the path's own boundary rather than falling off it. A time offset ("30m") lands between events and rounds to a real one: forward for a positive offset, backward for a negative one, unless offset_side says otherwise.

When a pattern is enough on its own

step_matrix(path_pattern=...) already centres on the position the pattern names — every block anchors on the same whole-pattern match, so path_pattern="cart->[^cart]*->shipping_details" gives one block centred on that cart and one on the checkout. If a centred matrix is all you need, the pattern alone does it, and a marker event adds nothing.

A marker earns its place when the position has to be addressed elsewhere: counted by a funnel, filtered on, used as a conversion anchor, compared across segments. It also reaches two things a pattern cannot say — occurrence, where the structural spelling gets unwieldy (cart->[^cart]*->pay->[^cart]*->path_end for "the last checkout attempt"), and offset, which has no pattern form at all for time ({"offset": "30m"}).