Event patterns
The tflo-cep crate composes typed signals into multi-event domain
signals — “user added to cart but did not purchase within 5 minutes,”
“page viewed then scrolled deep within 30 seconds,” “three identical
clicks within 1 second.” The API is closure-based, mirrors
tflo-cel / tflo-rhai / tflo-rego, and is
WASM-clean by construction.
The Rust crate, the WebAssembly bindings (tflo-cep-wasm), and the
TypeScript SDK (@tflo/browser-events) all drive the same
engine::Runtime — one state machine, three idiomatic surfaces.
Examples below run end-to-end through the WASM bridge in the browser.
The six builder methods
Patterns are linear sequences finalized by an emit closure:
| Method | Purpose |
|---|---|
.timestamp(\|e\| e.ts) | Event-time extractor (required for .within to work). |
.when(predicate) | Initial match — opens a partial match for every event the predicate accepts. |
.then(predicate) | Positive sequential step — advances a partial match. |
.not_then(predicate) | Negative terminal step — succeeds when no matching event arrives before the bound. |
.within(Duration) | Time bound on the previous step. Required after .not_then. |
.emit(\|m\| ...) | Finalize. The closure receives a Match<E> and returns the user's chosen output type. |
Pattern 1 — abandoned cart
Shape: when(A) → not_then(B) within T.
The pattern emits when an add_to_cart event isn't followed by a
purchase within 5 minutes. End-of-stream resolves any pending negatives,
so closing the tab still ships the signal.
Rust
use std::time::Duration;
use tflo_cep::prelude::*;
#[derive(Clone)]
struct Event { ts: i64, action: &'static str, cart_id: String }
let abandoned_cart = Pattern::<Event>::new("abandoned_cart")
.timestamp(|e| e.ts)
.when(|e| e.action == "add_to_cart")
.not_then(|e| e.action == "purchase")
.within(Duration::from_secs(5 * 60))
.emit(|m| format!("abandoned cart {}", m.first().cart_id))
.expect("pattern is well-formed");
let signals: Vec<String> = events.into_iter()
.match_pattern(abandoned_cart)
.collect(); TypeScript (browser, WASM)
import { Pattern, type EventRecord } from "@tflo/browser-events";
const abandonedCart = new Pattern<EventRecord>("abandoned_cart")
.timestamp((e) => e.ts)
.when((e) => e.kind === "add_to_cart")
.notThen((e) => e.kind === "purchase")
.within(5 * 60 * 1000)
.emit((m) => ({
name: "abandoned_cart",
payload: { cartId: m.first().fields.cartId },
}));
tflo.addPattern(abandonedCart); Pattern 2 — engaged with product
Shape: when(A) → then(B) within T.
Positive sequential: a product view followed by a deep scroll within 30 seconds is
engagement. The deep-scroll capture's timestamp gives you dwell-time pre-computed.
Rust
// "Viewed a product page, then scrolled deep into it within 30s."
let engaged = Pattern::<Event>::new("engaged_with_product")
.timestamp(|e| e.ts)
.when(|e| e.kind == "product_view")
.then(|e| e.kind == "deep_scroll")
.within(Duration::from_secs(30))
.emit(|m| (m.first().ts, m.last().ts))
.expect("pattern is well-formed"); TypeScript
const engaged = new Pattern<EventRecord>("engaged_with_product")
.timestamp((e) => e.ts)
.when((e) => e.kind === "product_view")
.then((e) => e.kind === "deep_scroll")
.within(30 * 1000)
.emit((m) => ({
name: "engaged_with_product",
payload: {
viewedAt: m.first().ts,
scrolledAt: m.last().ts,
durationMs: m.last().ts - m.first().ts,
},
})); Pattern 3 — rage click
Shape: when → then within → then within — three identical clicks within
one second each. The .repeated(n..=m, ...) quantifier sugar lands in
v0.2; for v0.1 you chain explicit .then().within() pairs, which is
exactly what the macro would compile to.
Rust
// "Three identical clicks within 1 second" — chained then-within.
let rage = Pattern::<Event>::new("rage_click")
.timestamp(|e| e.ts)
.when(|e| e.kind == "click" && e.target_id == "buy_button")
.then(|e| e.kind == "click" && e.target_id == "buy_button")
.within(Duration::from_millis(1000))
.then(|e| e.kind == "click" && e.target_id == "buy_button")
.within(Duration::from_millis(1000))
.emit(|m| format!("rage_click on {}", m.first().target_id))
.expect("pattern is well-formed"); TypeScript
const rage = new Pattern<EventRecord>("rage_click")
.timestamp((e) => e.ts)
.when((e) => e.kind === "click" && e.fields.targetId === "buy_button")
.then((e) => e.kind === "click" && e.fields.targetId === "buy_button")
.within(1_000)
.then((e) => e.kind === "click" && e.fields.targetId === "buy_button")
.within(1_000)
.emit((m) => ({
name: "rage_click",
payload: { targetId: m.first().fields.targetId },
})); The Match<E> interface
The emit closure receives a Match<E> — the captured events
in capture order. Both languages expose the same accessors:
Rust
.emit(|m: &Match<Event>| {
// first() and last() — always-present captures
let opened = m.first(); // the .when capture
let closed = m.last(); // the last positive capture
// at(name) — look up by step name
let checkout = m.at("checkout").unwrap();
// named() — iterate (name, event) pairs in capture order
for (step_name, ev) in m.named() {
eprintln!("step {step_name}: ts={}", ev.ts);
}
Signal::new("checkout_complete", closed.clone())
}) TypeScript
.emit((m) => {
// first() and last() — always-present captures
const opened = m.first();
const closed = m.last();
// at(name) — look up by step name
const checkout = m.at("checkout");
// all() — every captured event in order
for (const ev of m.all()) {
console.debug("captured", ev.kind, "ts=" + ev.ts);
}
return {
name: "checkout_complete",
payload: { sessionId: closed.fields.sessionId },
};
}) Bounded by construction
Partial matches are bounded by event rate within the within window
and hard-capped at MAX_IN_FLIGHT = 1024 per pattern — when the
cap is reached, the oldest partial match is dropped. This is the design rule
that keeps tflo-cep honest about edge / WASM memory budgets.
Negative matches (not_then) resolve in two ways: when their deadline
elapses without the predicate firing (success), or when the predicate fires
before the deadline (failure — the partial match drops). End-of-stream is
treated as a deadline that never closed, so streams that terminate before a
bound still ship their negative matches.
One engine, three surfaces
The matching state machine lives in tflo-cep::engine::Runtime, generic
over three callback traits:
Predicate<E>— closure-like predicate evaluation.EmitCallback<E, M>— match → output transformer.TimestampCallback<E>— event-time extractor.
tflo-cep ships Arc-based impls so patterns are Send + Sync;
tflo-cep-wasm ships Rc-based impls backed by js_sys::Function
so JS callbacks plug straight in. No duplicate state machine.
Feature additions land once.
Next: See the browser analytics page for the end-to-end workflow — capture → patterns → sinks — or browse the crate source.