browser wasm analytics patterns events tflo-cep

Capturing user interaction data with tflo in the browser

Most browser analytics either ships raw events to a SaaS that decides what counts as engagement, or hand-rolls a tangle of addEventListener calls that drifts from what marketing asked for. There's a third option.

12 min read

A visitor lands on your product page. They scroll halfway, click the “Add to cart” button, open the cart drawer, then close it and stay on the page for forty more seconds before leaving. What does your analytics stack make of that?

If you’re using a typical third-party SaaS, it ships every raw event — pointer moves, clicks, scrolls, visibility changes — to someone else’s server, and they decide whether to call it “engagement” or “cart abandonment.” The meaning of your events lives in a vendor’s dashboard.

If you’ve hand-rolled it instead, you’ve got a directory full of addEventListener calls, ad-hoc timeouts, and localStorage flags. It works until marketing asks for a new derived signal. Then somebody spends two days writing yet another bespoke wrapper and the file keeps growing.

There’s a third option. Derive the signals you care about, client-side, in code your team can read. The same Rust engine that runs on your edge gateway and your central worker — compiled to ~40 KB of WebAssembly — runs in the browser and produces typed domain signals from raw DOM events. The signals are what you ship to your warehouse, GA4, Segment, or your own collector. The vendors only ever see the meaning, not the firehose.

This is what tflo-cep (closure-based event-pattern matching) and the companion @tflo/browser-events TypeScript SDK make practical.

The shape of the problem

Browser-side analytics is event-pattern matching disguised as a tooling problem. Almost every real signal anyone cares about is the same shape:

  • abandoned_cart — user added to cart, then did not purchase within N minutes
  • engaged_with_product — user viewed a product page, then scrolled past N% of the content within T seconds
  • rage_click — three or more clicks on the same target within one second
  • time_on_section — section A entered the viewport, then left the viewport, here’s the duration
  • abandoned_checkout — user began checkout, then did not complete within session

Each is a sequence of two or three events, sometimes with a “within T” bound, sometimes with a “not followed by” clause. None of them are exotic. All of them are tedious to hand-roll, because the patterns are similar but each one drifts away from the others as the codebase ages.

The right primitive is a small declarative DSL that expresses these shapes uniformly. That’s what tflo-cep is.

The engine: same Rust, three surfaces

tflo-cep’s API is six methods. You build a pattern, you chain steps, you finalize with an emit closure:

use std::time::Duration;
use tflo_cep::prelude::*;

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!("cart {} abandoned", m.first().cart_id))
    .expect("pattern is well-formed");

That’s the Rust API. It’s the same six methods the engine exposes in the browser — timestamp, when, then, notThen, within, emit — because in the browser we wrap the same engine::Runtime with Rc-based, JS-callback-backed adapters instead of Arc-based ones:

import { Pattern, type EventRecord } from "@tflo/browser-events";

const abandonedCart = new Pattern<EventRecord>("abandoned_cart")
  .timestamp((e) => e.ts)
  .when((e) => e.fields.action === "add_to_cart")
  .notThen((e) => e.fields.action === "purchase")
  .within(5 * 60 * 1000)
  .emit((m) => ({
    name: "abandoned_cart",
    payload: { cartId: m.first().fields.cartId },
  }));

Same semantics. One state machine. The closures translate directly — Fn(&E) -> bool in Rust, (e: E) => boolean in TypeScript. The matching engine doesn’t know or care which one called it.

That last bit — the matching engine doesn’t know or care — is the load-bearing claim. The closure-based DSL plays exactly the same role in both languages, on top of an engine::Runtime that’s parameterized over predicate, emit, and timestamp callback traits. Future features (quantifiers, CEL-string predicates, iterative conditions) land in one place and both surfaces inherit.

Capture: pulled apart from the engine on purpose

The matching engine is one layer. Getting events into it is a separate layer, and the SDK keeps them that way.

@tflo/browser-events ships three first-class capture helpers:

  • capture(...) — a thin wrapper around addEventListener that produces normalized EventRecord objects with ts, kind, and a user-defined fields payload. Optional throttling for scroll, wheel, pointermove.
  • captureViewport(...) — backed by IntersectionObserver. Emits viewport:enter, viewport:exit, and viewport:dwell (with durationMs pre-computed) for any matched element. This is the “time on section” primitive.
  • ingest(record) — direct push for events you capture yourself.

Wiring all three for a content page looks like:

const tflo = new TFloBrowser({
  sinks: [new ConsoleSink(), new EdgeSink({ endpoint: "/collect" })],
});
await tflo.init();

// Time on every <section> with a data-section-id.
tflo.captureViewport({
  target: document,
  selector: "section[data-section-id]",
  threshold: 0.5,
});

// Click capture with payload extraction.
tflo.capture<MouseEvent>({
  target: document,
  type: "click",
  fields: (e) => {
    const t = (e.target as HTMLElement | null)
      ?.closest("[data-analytics-action]");
    return t
      ? { action: t.dataset.analyticsAction, cartId: t.dataset.cartId }
      : {};
  },
});

window.addEventListener("pagehide", () => void tflo.flush());

That covers the entire capture surface for most product pages. Every emitted event is an EventRecord — no vendor-specific shape, no DOM objects leaking into your data model.

Patterns over captured events

With capture wired up, you attach patterns. The viewport helper alone gives you per-section dwell durations directly (its viewport:dwell event includes durationMs), so for “time on section” you don’t even need a pattern — you can pipe the dwell events straight to your sink.

For the multi-event cases, the patterns are short:

// "Viewed pricing then dwelled on it for more than 5 seconds."
const pricingEngaged = new Pattern<EventRecord>("pricing_engaged")
  .timestamp((e) => e.ts)
  .when((e) =>
    e.kind === "viewport:enter" && e.fields.sectionId === "pricing"
  )
  .then((e) =>
    e.kind === "viewport:dwell"
    && e.fields.sectionId === "pricing"
    && (e.fields.durationMs as number) > 5_000
  )
  .within(60_000)
  .emit((m) => ({
    name: "pricing_engaged",
    payload: { dwellMs: m.last().fields.durationMs },
  }));

// "Three clicks on the same buy button within 1 second."
const rageClick = 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 },
  }));

tflo.addPattern(pricingEngaged);
tflo.addPattern(rageClick);

A few things worth noticing about that code:

  • The closures express intent directly. “Section was pricing,” “dwell exceeded 5 seconds,” “target id was buy_button.” A new engineer on the team can read those checks without learning a query language.
  • within(...) bounds the partial-match state. The matching engine is bounded-by-construction; in-flight partial matches are capped per pattern. Nothing balloons silently on a busy page.
  • End-of-stream resolves pending negatives. When the tab closes, tflo.flush() drains not_then patterns whose deadlines hadn’t elapsed yet — so abandoned-cart signals ship reliably via sendBeacon even when the user navigates away.

Sinks: GA4 is one option, not THE option

The matching engine has zero analytics-vendor knowledge. The sink layer is a generic interface:

interface Sink {
  readonly name: string;
  send(signal: DerivedSignal): void | Promise<void>;
  flush?(): void | Promise<void>;
}

@tflo/browser-events bundles three implementations:

SinkUse
ConsoleSinkLocal development. Works in any JS environment.
EdgeSinkPOST to your own collector. Batches, falls back to sendBeacon on unload. Keeps shared secrets server-side.
GA4SinkForwards to GA4 via gtag.js. Gracefully no-ops without gtag.js.

Adding Segment, Mixpanel, or your own warehouse is a fifty-line class — implement send(signal) and add it to the constructor:

class SegmentSink implements Sink {
  readonly name = "segment";
  send(signal: DerivedSignal): void {
    window.analytics?.track(signal.name, signal.payload);
  }
}

new TFloBrowser({
  sinks: [new ConsoleSink(), new SegmentSink(), new EdgeSink({ ... })],
});

Signals can target specific sinks (sinks: ["edge"] in the emit output) or fan out to every registered sink when the field is omitted. Per-sink failures are isolated so one bad endpoint doesn’t block the others.

The Measurement Protocol path for GA4 is intentionally not supported direct from the browser — its api_secret must never appear in client code. Use EdgeSink to ship to your own server, then forward to GA4 from there. The pattern works for ClickHouse, BigQuery, anything else that needs auth.

The wire-weight numbers

Across the stack, the cost looks like this:

  • tflo_cep_wasm_bg.wasm (release, wasm-opt’d) — ~41 KB
  • Generated JS glue — ~22 KB
  • TypeScript runtime (@tflo/browser-events) — ~10 KB minified
  • Total over the wire (gzip) — ~25 KB

For comparison: a minified GA4 gtag.js snippet is about 60 KB. tflo ships the pattern engine in less than half of what a single analytics tag costs.

What this gets you

The architectural payoff is small in code but big in operating posture:

  • The meaning of your events lives in your repo. When marketing asks for a new derived signal, you write a five-line pattern, run the tests, and ship. You don’t file a vendor ticket.
  • The same patterns work server-side. The Rust tflo-cep crate that runs in your edge gateway and central worker is the same engine. Re-deploy a pattern from server to browser is a 30-line port.
  • You control what leaves the page. Sinks are pluggable; the consent gate is one function. Raw events stay client-side unless a pattern and a sink both agreed to ship them.
  • The wire weight is honest. ~25 KB gzip for client-side derivation. No surprise SDK bloat, no vendor lock-in.

If you’ve got a marketing or product team that keeps asking for signals and a frontend team that keeps writing addEventListener wrappers, this is the seam.

Try it

Three repos:

Docs walk-through: browser analytics page and event patterns reference.