Routing
Every chain field decides where an event goes next. This page is the reference
for connections, chain resolution, route operators and stop. The examples come
from flow-complete.json,
the tested example flow, so each one is a real part of a working setup.
Connection rules
Sources, transformers, collectors, and destinations connect in specific ways. This table summarizes every valid connection:
| From | To | Field | Notes |
|---|---|---|---|
| Source | Transformer | before on source | Consent-exempt preprocessing |
| Source | Transformer | next on source | Source chain, runs before the collector |
| Source | Collector | omit next | Default: events go straight to collector |
| Transformer | Transformer | before on transformer | Pre-transform enrichment |
| Transformer | Transformer | next on transformer | Chain continues to next transformer |
| Collector | Transformer | next on collector | Collector chain, runs once per event before the destination fan-out |
| Collector | Transformer | before on destination | Destination chain, runs for that destination only |
| Collector | Destination | omit collector.next and before | Default: events go straight to every destination |
| Destination | Transformer | next on destination | Post-push processing |
Connections that are not allowed:
- Source to source
- Source directly to destination (events must pass through the collector)
- Collector to source
Chain resolution
Every chain field (source.before, source.next, transformer.before, transformer.next, collector.next, destination.before, destination.next) accepts the same value and resolves it the same way:
| Value | Behavior |
|---|---|
"fingerprint" (string) | Runs fingerprint, then its own next, and so on until a step has no next |
["fingerprint", "enrich"] (array) | Runs the array in order. A member's own next is inserted right after that member, then the array continues |
For example, ["bot", "validate", "session"] where bot has "next": "flag" runs bot, flag, validate, session. Insertion is recursive: if flag has a next of its own, it runs right after flag. A string is the one-member case of the same rule.
The rules that apply everywhere:
- Resolved per hop: each route is evaluated when the event reaches it, never ahead of time. Its
matchreads{ ingest, event }as the previous step left them, so a route can decide on a value an earlier step loaded, for example astateget that writesingest.tier. A conditionalnexton a transformer sees what that transformer did. - Repeated steps run as written: a step listed twice runs twice.
- Cycles: a transformer's own
nextthat leads back to a step already on that insertion path is skipped. Each copy of an event is also limited to 256 steps. - Unknown ids: an id that names no transformer is logged as a warning and skipped, and the chain continues.
walkeros validatereports it as anUNKNOWN_ROUTE_TARGETerror. - Forks and stops:
manysplits the event into independent copies andstopends one copy, see Route operators and Dropping an event withstop.
Where transformers run
All transformers live in a single transformers pool. Their position in the pipeline depends on which field references them:
[Source.before] → Source ──[next]──→ Source chain ──→ Collector ──[next]──→ Collector chain ──→ fan-out ──[before]──→ Destination chain ──→ Destination ──[next]──→ Post-push
The same transformer can appear in several chains. For example, you might use fingerprint in a source's next chain and again in a destination's before chain. Each invocation runs independently.
Use collector.next for work every destination needs, and destination.before for work only one destination needs. The collector chain runs once per event, after the collector completed it (globals, user, consent, ids) and before the event is handed to the destinations.
The server flow of the complete example uses every position. The source chain
stops an opted-out user (see Opt-out), then
runs dedup before the collector:
[
{
"match": {
"key": "event.user.optout",
"operator": "eq",
"value": "true"
},
"stop": true
},
"dedup"
]chains-routingThe collector chain runs once per event, for every destination: fingerprint
and bot always, enrich for everything but impressions.
[
"fingerprint",
"bot",
{
"match": {
"key": "event.trigger",
"operator": "regex",
"value": "^(impression|visible)$",
"not": true
},
"next": "enrich"
}
]chains-routingOn the web, collector.next stops an opted-out user, then runs one inline
step:
[
{
"match": {
"key": "event.user.optout",
"operator": "eq",
"value": "true"
},
"stop": true
},
"pageGroup"
]chains-routingThe inline code step derives globals.pageGroup from the page path once per event, so every destination can group pages the same way.
{
"name": "order complete",
"entity": "order",
"action": "complete",
"data": {
"id": "ORD-100",
"total": 149.8,
"currency": "EUR",
"coupon": "WELCOME10"
},
"id": "ev-order-complete",
"trigger": "load",
"timestamp": 1700000000000,
"consent": {
"functional": true,
"marketing": true
},
"globals": {
"language": "en"
},
"user": {
"device": "d3v1c3",
"session": "s3ss10n"
},
"source": {
"type": "browser",
"platform": "web",
"url": "https://www.example.com/checkout/thanks"
},
"nested": [
{
"entity": "product",
"data": {
"id": "SKU-1",
"name": "Trail Runner",
"price": 129.9,
"quantity": 1
}
},
{
"entity": "product",
"data": {
"id": "SKU-2",
"name": "Running Socks",
"price": 9.95,
"quantity": 2
}
}
]
}return {
"event": {
"name": "order complete",
"entity": "order",
"action": "complete",
"data": {
"id": "ORD-100",
"total": 149.8,
"currency": "EUR",
"coupon": "WELCOME10"
},
"id": "ev-order-complete",
"trigger": "load",
"timestamp": 1700000000000,
"consent": {
"functional": true,
"marketing": true
},
"globals": {
"language": "en",
"pageGroup": "checkout"
},
"user": {
"device": "d3v1c3",
"session": "s3ss10n"
},
"source": {
"type": "browser",
"platform": "web",
"url": "https://www.example.com/checkout/thanks"
},
"nested": [
{
"entity": "product",
"data": {
"id": "SKU-1",
"name": "Trail Runner",
"price": 129.9,
"quantity": 1
}
},
{
"entity": "product",
"data": {
"id": "SKU-2",
"name": "Running Socks",
"price": 9.95,
"quantity": 2
}
}
]
}
}checkout of step pageGroup. Guide chapter chains-routingA destination chain runs for one destination only. Meta, Piwik PRO and Data
Manager each name eventFilter; Pub/Sub, which feeds the warehouse, does not:
/flows/server/destinations/meta/before: "eventFilter". destination.before runs for one destination only; a stop there skips just that one. Guide chapter chains-routing
A destination chain can be a route too. On Pub/Sub, pseudonymize runs only
when an email exists:
{
"match": {
"key": "event.user.email",
"operator": "exists",
"value": ""
},
"next": "pseudonymize"
}consent-privacyConditional routing
Every chain field (source.before, source.next, transformer.before, transformer.next, collector.next, destination.before, destination.next) accepts a Route. Route is recursive:
type Route = string | Route[] | RouteConfig;
A string names a transformer (or a path, see below). A Route[] runs each entry in order as a pipeline. A RouteConfig is a disjoint union: it sets exactly one of next, one, many, stop, or none of them (pure gate that only filters by match). match is optional on next, one, many and stop; omit it to always match. A pure gate requires match.
At every position, match reads the same root: { ingest, event }. Use ingest.* for request context and event.* for the event as it is at that point (at source.before, the source's raw output). Each route is evaluated only when the event reaches it.
Route operators
A RouteConfig is a disjoint union. Pick the operator that matches the routing shape you need:
next: single continuation. Optionally gated bymatch.one: first-match dispatch. Walk entries in order; the first whosematchpasses wins, and the chain continues with that entry'sRoute.many: all-match fan-out, allowed in every chain field. Every matching entry becomes its own copy of the event, and each copy finishes the rest of the path on its own: the rest of the array, every enclosing chain, and the positions after it (collector, destinations). Copies are never merged. If only one entry matches, the event continues without a copy.stop:{ "stop": true }ends the running copy of the event. Withmatch, it ends the copy only when the match passes; otherwise the chain continues.
An array made only of route configs, with no transformer id in it, is an implicit one: the first matching entry wins. Write { "one": [...] } to make that explicit, or add a transformer id to make it a sequence.
First-match dispatch is the one operator. The server source of the complete
example sorts raw requests before an event exists:
{
"one": [
{
"match": {
"and": [
{
"key": "ingest.path",
"operator": "prefix",
"value": "/g/collect"
},
{
"key": "ingest.url",
"operator": "regex",
"value": "[?&]tid=G-SUBSITE(&|$)"
}
]
},
"next": "ga4Decode"
},
{
"match": {
"key": "ingest.path",
"operator": "suffix",
"value": ".js"
},
"next": "file"
},
{
"match": {
"key": "ingest.path",
"operator": "prefix",
"value": "/g/collect"
},
"stop": true
}
]
}chains-routing[
{
"match": {
"and": [
{
"key": "ingest.path",
"operator": "prefix",
"value": "/g/collect"
},
{
"key": "ingest.url",
"operator": "regex",
"value": "[?&]tid=G-SUBSITE(&|$)"
}
]
},
"next": "ga4Decode"
},
{
"match": {
"key": "ingest.path",
"operator": "suffix",
"value": ".js"
},
"next": "file"
},
{
"match": {
"key": "ingest.path",
"operator": "prefix",
"value": "/g/collect"
},
"stop": true
}
]chains-routingone entries are evaluated in order and the first matching Route wins: a GA4
hit of the sub-site property is decoded, a script request is served, and any
other GA4 hit stops. An entry without match always matches and acts as a
default branch. If no entry matches, as for a POST /collect, the route selects
nothing and the event continues unchanged, here to the source's next.
{
"match": {
"key": "ingest.path",
"operator": "prefix",
"value": "/g/collect"
},
"stop": true
}chains-routingThe conditions use the combinator and and three operators:
[
{
"key": "ingest.path",
"operator": "prefix",
"value": "/g/collect"
},
{
"key": "ingest.url",
"operator": "regex",
"value": "[?&]tid=G-SUBSITE(&|$)"
}
]chains-routing/flows/server/sources/express/before/one/0/match/and/0/operator: "prefix". prefix matches the start of a value. Guide chapter chains-routing
/flows/server/sources/express/before/one/0/match/and/1/operator: "regex". regex matches a pattern: only hits of the sub-site property are decoded. Guide chapter chains-routing
/flows/server/sources/express/before/one/1/match/operator: "suffix". suffix matches the end of a value: script requests end in .js. Guide chapter chains-routing
Conditional next and sequences
A conditional next is a match plus a next: the target runs only when the
match holds, otherwise the chain goes on. not inverts a condition:
{
"match": {
"key": "event.trigger",
"operator": "regex",
"value": "^(impression|visible)$",
"not": true
},
"next": "enrich"
}chains-routing/flows/server/collector/next/2/match/not: true. not inverts a condition: everything that is not an impression. Guide chapter chains-routing
An array that mixes conditional next entries and step ids is a sequence. Each
entry decides in turn, and a plain id always runs. The enrich step of the
complete example is such a sequence: the customer lookup only for logged-in
events, the session steps only for their event, validate always last.
[
{
"match": {
"key": "event.user.id",
"operator": "exists",
"value": ""
},
"next": "loadUser"
},
{
"match": {
"key": "event.name",
"operator": "eq",
"value": "session start"
},
"next": "sessionSave"
},
{
"match": {
"key": "event.name",
"operator": "eq",
"value": "order complete"
},
"next": "sessionLoad"
},
"validate"
]chains-routing/flows/server/transformers/enrich/next/0/match/operator: "exists". exists checks that a path has a value: only logged-in events load the customer. Guide chapter chains-routing
/flows/server/transformers/enrich/next/1/match/operator: "eq". eq compares as strings. Guide chapter chains-routing
A logged-in order loads the customer lifetime value, restores the session with its gclid and is validated against the server contract, in that order.
{
"name": "order complete",
"entity": "order",
"action": "complete",
"data": {
"id": "ORD-100",
"total": 149.8,
"currency": "EUR",
"coupon": "WELCOME10"
},
"id": "ev-order-complete",
"trigger": "load",
"timestamp": 1700000000000,
"consent": {
"functional": true,
"marketing": true
},
"globals": {
"language": "en",
"pageGroup": "checkout"
},
"user": {
"device": "d3v1c3",
"session": "s3ss10n",
"id": "cust-42",
"email": "jane@example.com",
"hash": "5f1c0a9e7d3b2c41",
"botScore": 0,
"botCategory": "human"
},
"source": {
"type": "express",
"platform": "server",
"url": "https://www.example.com/checkout/thanks"
},
"nested": [
{
"entity": "product",
"data": {
"id": "SKU-1",
"name": "Trail Runner",
"price": 129.9,
"quantity": 1
}
},
{
"entity": "product",
"data": {
"id": "SKU-2",
"name": "Running Socks",
"price": 9.95,
"quantity": 2
}
}
]
}return {
"event": {
"name": "order complete",
"entity": "order",
"action": "complete",
"data": {
"id": "ORD-100",
"total": 149.8,
"currency": "EUR",
"coupon": "WELCOME10"
},
"id": "ev-order-complete",
"trigger": "load",
"timestamp": 1700000000000,
"consent": {
"functional": true,
"marketing": true
},
"globals": {
"language": "en",
"pageGroup": "checkout"
},
"user": {
"device": "d3v1c3",
"session": "s3ss10n",
"id": "cust-42",
"email": "jane@example.com",
"hash": "5f1c0a9e7d3b2c41",
"botScore": 0,
"botCategory": "human",
"ltv": 420,
"segment": "loyal"
},
"source": {
"type": "express",
"platform": "server",
"url": "https://www.example.com/checkout/thanks",
"valid": true
},
"nested": [
{
"entity": "product",
"data": {
"id": "SKU-1",
"name": "Trail Runner",
"price": 129.9,
"quantity": 1
}
},
{
"entity": "product",
"data": {
"id": "SKU-2",
"name": "Running Socks",
"price": 9.95,
"quantity": 2
}
}
]
}
}orderComplete of step enrich. Guide chapter chains-routingFan-out is the many operator. Use it when one inbound event needs to feed several independent branches, for example a parser plus an audit log:
{
"sources": {
"http": {
"package": "@walkeros/server-source-express",
"next": {
"many": [
{
"match": { "key": "ingest.path", "operator": "prefix", "value": "/purchase" },
"next": "purchase_parser"
},
{ "next": "audit_log" }
]
}
}
}
}A request to /purchase matches both entries, so it becomes two copies: one runs purchase_parser, the other audit_log, and each then continues to the collector and the destinations on its own. Any other request matches only audit_log and continues without a copy. If no entry matches, the event continues unchanged.
Each copy gets its own event.id, derived from the original id and the branch position, so deduplication on event.id (and vendor deduplication such as Meta's event_id) keeps every copy. The copies share the same trace. A transformer that returns an array of results forks the same way.
many works in every chain field and at any depth. Inside an array, each copy finishes the rest of the array:
"next": ["decode", { "many": ["enrich_a", "enrich_b"] }, "session"]
This runs decode, then two copies: enrich_a then session, and enrich_b then session.
The complete example has no genuine use for many, transformer.before or
destination.next. The tested route fixture routeCases in @walkeros/core/dev
covers them.
Dropping an event with stop
A stop entry ends the running copy of the event. Combine it with match to drop only some events; without match it always stops. A stop that does not match falls through and the chain continues. In a many, a stop ends only its own copy; the other copies continue.
What a stop means depends on the position it is resolved in:
| Position | A resolved stop means |
|---|---|
source.before, source.next | The event never reaches the collector. The push result is { ok: true, dropped: true } |
transformer.before, transformer.next, a transformer result { next } | The running copy ends; the position that started the chain applies its own meaning |
collector.next | No destination receives the event |
destination.before | This destination skips the event; other destinations are unaffected |
destination.next | The post-push chain ends; delivery already happened |
collector.next runs before the destination fan-out, so a stop there drops the event for all destinations. To drop an event for one destination only, put the stop in that destination's before.
Drop product impressions everywhere:
"collector": {
"next": [
{ "match": { "key": "event.name", "operator": "eq", "value": "product impression" }, "stop": true },
"session"
]
}
Drop events for some vendors only: in the complete example, Meta, Piwik PRO and
Data Manager name eventFilter as their before. Its own next is a stop
with an or match: impressions, likely bots and events that failed the
contract. Pub/Sub does not name it, so the warehouse keeps every event.
{
"next": {
"match": {
"or": [
{
"key": "event.trigger",
"operator": "regex",
"value": "^(impression|visible)$"
},
{
"key": "event.user.botScore",
"operator": "gt",
"value": "50"
},
{
"key": "event.source.valid",
"operator": "eq",
"value": "false"
}
]
},
"stop": true
},
"examples": { … }
}chains-routing{
"match": {
"or": [
{
"key": "event.trigger",
"operator": "regex",
"value": "^(impression|visible)$"
},
{
"key": "event.user.botScore",
"operator": "gt",
"value": "50"
},
{
"key": "event.source.valid",
"operator": "eq",
"value": "false"
}
]
},
"stop": true
}chains-routing[
{
"key": "event.trigger",
"operator": "regex",
"value": "^(impression|visible)$"
},
{
"key": "event.user.botScore",
"operator": "gt",
"value": "50"
},
{
"key": "event.source.valid",
"operator": "eq",
"value": "false"
}
]chains-routing/flows/server/transformers/eventFilter/next/match/or/1/operator: "gt". gt compares numbers. Guide chapter chains-routing
/flows/server/transformers/eventFilter/next/stop: true. stop with a match drops the event at that point. Guide chapter chains-routing
A valid event from a human reaches the vendor.
{
"name": "order complete",
"entity": "order",
"action": "complete",
"data": {
"id": "ORD-100",
"total": 149.8,
"currency": "EUR",
"coupon": "WELCOME10"
},
"id": "ev-order-complete",
"trigger": "load",
"timestamp": 1700000000000,
"consent": {
"functional": true,
"marketing": true
},
"globals": {
"language": "en",
"pageGroup": "checkout"
},
"user": {
"device": "d3v1c3",
"session": "s3ss10n",
"id": "cust-42",
"email": "jane@example.com",
"hash": "5f1c0a9e7d3b2c41",
"botScore": 0,
"botCategory": "human",
"ltv": 420
},
"source": {
"type": "express",
"platform": "server",
"url": "https://www.example.com/checkout/thanks",
"valid": true
},
"nested": [
{
"entity": "product",
"data": {
"id": "SKU-1",
"name": "Trail Runner",
"price": 129.9,
"quantity": 1
}
},
{
"entity": "product",
"data": {
"id": "SKU-2",
"name": "Running Socks",
"price": 9.95,
"quantity": 2
}
}
]
}return {
"event": {
"name": "order complete",
"entity": "order",
"action": "complete",
"data": {
"id": "ORD-100",
"total": 149.8,
"currency": "EUR",
"coupon": "WELCOME10"
},
"id": "ev-order-complete",
"trigger": "load",
"timestamp": 1700000000000,
"consent": {
"functional": true,
"marketing": true
},
"globals": {
"language": "en",
"pageGroup": "checkout"
},
"user": {
"device": "d3v1c3",
"session": "s3ss10n",
"id": "cust-42",
"email": "jane@example.com",
"hash": "5f1c0a9e7d3b2c41",
"botScore": 0,
"botCategory": "human",
"ltv": 420
},
"source": {
"type": "express",
"platform": "server",
"url": "https://www.example.com/checkout/thanks",
"valid": true
},
"nested": [
{
"entity": "product",
"data": {
"id": "SKU-1",
"name": "Trail Runner",
"price": 129.9,
"quantity": 1
}
},
{
"entity": "product",
"data": {
"id": "SKU-2",
"name": "Running Socks",
"price": 9.95,
"quantity": 2
}
}
]
}
}validPasses of step eventFilter. Guide chapter chains-routingWithout match, a stop always ends the copy. The file step serves walker.js
and stops, so a script request never becomes an event:
{
"stop": true
}chains-routingA dropped event is recorded as a skip with reason dropped in the flow's observability data. walkeros validate warns about entries placed after an unconditional stop, because they can never run.
To name and reuse a chain without writing code, declare a path transformer: a transformer entry with no code / package, just before / next / cache. The collector synthesizes a code-less passthrough so the named entry can be referenced from any Route:
{
"next": [ … ],
"examples": { … }
}chains-routing