Skip to main content
Ask your AI

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:

FromToFieldNotes
SourceTransformerbefore on sourceConsent-exempt preprocessing
SourceTransformernext on sourceSource chain, runs before the collector
SourceCollectoromit nextDefault: events go straight to collector
TransformerTransformerbefore on transformerPre-transform enrichment
TransformerTransformernext on transformerChain continues to next transformer
CollectorTransformernext on collectorCollector chain, runs once per event before the destination fan-out
CollectorTransformerbefore on destinationDestination chain, runs for that destination only
CollectorDestinationomit collector.next and beforeDefault: events go straight to every destination
DestinationTransformernext on destinationPost-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:

ValueBehavior
"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:

  1. Resolved per hop: each route is evaluated when the event reaches it, never ahead of time. Its match reads { ingest, event } as the previous step left them, so a route can decide on a value an earlier step loaded, for example a state get that writes ingest.tier. A conditional next on a transformer sees what that transformer did.
  2. Repeated steps run as written: a step listed twice runs twice.
  3. Cycles: a transformer's own next that leads back to a step already on that insertion path is skipped. Each copy of an event is also limited to 256 steps.
  4. Unknown ids: an id that names no transformer is logged as a warning and skipped, and the chain continues. walkeros validate reports it as an UNKNOWN_ROUTE_TARGET error.
  5. Forks and stops: many splits the event into independent copies and stop ends one copy, see Route operators and Dropping an event with stop.

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:

/flows/server/sources/express/next
[
  {
    "match": {
      "key": "event.user.optout",
      "operator": "eq",
      "value": "true"
    },
    "stop": true
  },
  "dedup"
]
source.next names the hops after the source: the opt-out stop, then dedup. Guide chapter chains-routing

The collector chain runs once per event, for every destination: fingerprint and bot always, enrich for everything but impressions.

/flows/server/collector/next
[
  "fingerprint",
  "bot",
  {
    "match": {
      "key": "event.trigger",
      "operator": "regex",
      "value": "^(impression|visible)$",
      "not": true
    },
    "next": "enrich"
  }
]
collector.next runs once per event before the fan-out: fingerprint and bot for all, enrich for all but impressions. Guide chapter chains-routing

On the web, collector.next stops an opted-out user, then runs one inline step:

/flows/web/collector/next
[
  {
    "match": {
      "key": "event.user.optout",
      "operator": "eq",
      "value": "true"
    },
    "stop": true
  },
  "pageGroup"
]
On the web collector.next stops an opted-out user first, then runs the inline pageGroup step once per event. Guide chapter chains-routing

The inline code step derives globals.pageGroup from the page path once per event, so every destination can group pages the same way.

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"
  },
  "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
      }
    }
  ]
}
Out
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 page group: example checkout of step pageGroup. Guide chapter chains-routing

A 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:

/flows/server/destinations/pubsub/before
{
  "match": {
    "key": "event.user.email",
    "operator": "exists",
    "value": ""
  },
  "next": "pseudonymize"
}
A conditional next on Pub/Sub runs pseudonymize only when an email exists; other events pass untouched. Guide chapter consent-privacy

Conditional 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 by match.
  • one: first-match dispatch. Walk entries in order; the first whose match passes wins, and the chain continues with that entry's Route.
  • 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. With match, 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:

/flows/server/sources/express/before
{
  "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
    }
  ]
}
source.before runs on the raw request, before an event exists. Guide chapter chains-routing
/flows/server/sources/express/before/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
  }
]
one takes the first matching branch; no match falls through. Guide chapter chains-routing

one 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.

/flows/server/sources/express/before/one/2
{
  "match": {
    "key": "ingest.path",
    "operator": "prefix",
    "value": "/g/collect"
  },
  "stop": true
}
GA4 hits for any other property stop at the route instead of reaching the collector undecoded. Guide chapter chains-routing

The conditions use the combinator and and three operators:

/flows/server/sources/express/before/one/0/match/and
[
  {
    "key": "ingest.path",
    "operator": "prefix",
    "value": "/g/collect"
  },
  {
    "key": "ingest.url",
    "operator": "regex",
    "value": "[?&]tid=G-SUBSITE(&|$)"
  }
]
and needs every condition. Guide chapter 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:

/flows/server/collector/next/2
{
  "match": {
    "key": "event.trigger",
    "operator": "regex",
    "value": "^(impression|visible)$",
    "not": true
  },
  "next": "enrich"
}
A conditional next (match plus next) runs the target only when the match holds; otherwise the chain goes on. Guide chapter 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.

/flows/server/transformers/enrich/next
[
  {
    "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"
]
A list mixing conditional next entries and step ids is a sequence: each match decides in turn, validate always runs last. Guide chapter 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.

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"
  },
  "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
      }
    }
  ]
}
Out
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
        }
      }
    ]
  }
}
Enrich an order: example orderComplete of step enrich. Guide chapter chains-routing

Fan-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:

PositionA resolved stop means
source.before, source.nextThe 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.nextNo destination receives the event
destination.beforeThis destination skips the event; other destinations are unaffected
destination.nextThe 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.

/flows/server/transformers/eventFilter
{
  "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": { … }
}
eventFilter keeps impressions, likely bots and invalid events from Meta, Piwik PRO and Data Manager; Pub/Sub keeps everything. Guide chapter chains-routing
/flows/server/transformers/eventFilter/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
}
A transformer next continues the chain after the step. Guide chapter chains-routing
/flows/server/transformers/eventFilter/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"
  }
]
or needs any condition. Guide chapter 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.

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
      }
    }
  ]
}
Out
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
        }
      }
    ]
  }
}
Valid human order passes: example validPasses of step eventFilter. Guide chapter chains-routing

Without match, a stop always ends the copy. The file step serves walker.js and stops, so a script request never becomes an event:

/flows/server/transformers/file/next
{
  "stop": true
}
An unconditional stop: a script request never becomes an event. Guide chapter chains-routing

A 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:

/flows/server/transformers/enrich
{
  "next": [ … ],
  "examples": { … }
}
A step with only next is a named chain other routes can call. Guide chapter chains-routing
💡 Need implementation support?
elbwalker offers hands-on support: setup review, measurement planning, destination mapping, and live troubleshooting. Book a 2-hour session (€399)