ReviewOS

stacks/analyticshq

[feature] Saved segments + regex filtering

#23
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Gap

We already have a strong click-to-filter engine across ~13 dimensions (dashboard.stx:45-146) — but no saved segments and no regex matching. Plausible/Fathom offer both.

Proposed

Persist named segments (filter combinations) per user/site; add regex operators to the filter engine and Stats API.

Priority: P2.


From the 2026-07-20 privacy-first competitive audit (vs. Plausible / Fathom / Simple Analytics / Umami).

9 days ago

Note from #21 (funnels, shipped in 34ce7f0), because this issue is where the guard belongs.

Funnels currently have no small-count floor. A funnel step reading "1" describes a single person's sequence of actions. On its own that is harmless — "1 person completed checkout yesterday" identifies nobody.

It stops being harmless once segments land. A saved segment narrows the population before the count is taken, and the combination is the classic re-identification shape:

country = Iceland + device = desktop + referrer = internal-wiki → funnel step 3: 1

Each filter is individually innocuous and aggregate. Together they describe one person's behaviour to anyone who can guess the segment — and on a low-traffic site the guess is easy.

This is worth deciding deliberately when segments are built, rather than inheriting by accident:

  • a k-anonymity floor (suppress or bucket any cell below k, k≈5) applied to segmented results, not to unsegmented totals — the unsegmented case does not need it and suppressing there would just make the product look broken
  • or a minimum segment population check, refusing to apply a segment that matches fewer than k identities at all, which fails earlier and is easier to explain to the user
  • or explicitly deciding the risk is acceptable and writing down why

Flagging rather than pre-building it: the right threshold and the right failure mode depend on how segments end up being expressed, and guessing now would mean a guard shaped for an API that does not exist yet.

Worth noting this is a place the product could be genuinely stricter than Plausible/Fathom/Umami rather than matching them, which is the stated positioning.

9 days ago

Shipped in 1d66ecf. (The k-anonymity note above still stands as future work — see the end of this comment.)

What landed

Five operators, with the operator in the parameter name:

?path=/pricing              equals (unchanged)
?path__not=/admin           not equals
?path__contains=blog        substring
?path__matches=^/blog/\d+   regular expression
?path__not_matches=^/admin  negated regular expression

A value prefix like ?path=~^/blog/ was the tempting alternative, but it makes a literal value starting with ~ unrepresentable and needs an escaping rule someone eventually gets wrong. A name suffix cannot collide with any value. Bare ?country=US is untouched, so every existing dashboard link and click-to-filter URL keeps working.

Saved segments store exactly that key/value bag, so applying one (?segment=<id>) is a merge, not a translation. A second code path turning segments into SQL would be free to disagree with the live filters, and two spellings of the same question drifting apart is very hard to notice from outside. Request params win over the saved definition — a segment is a starting point the reader narrows by clicking.

GET /api/filters publishes the grammar so a UI need not hardcode it.

Two semantics that are easy to state and easy to get wrong

Negation must include rows with no value. country <> 'US' is NULL — therefore false — for a visitor we could not geolocate, so the obvious spelling silently drops them. It returns a smaller number rather than an error, which is the worst kind of bug. Written as IS DISTINCT FROM, and (col IS NULL OR col !~ ?) for the pattern form.

contains uses POSITION, not LIKE. A % inside a parameterised LIKE value is still a wildcard, so ?path__contains=50% would match far more than it says. POSITION has no wildcard semantics, so there's no escaping rule to get wrong.

Both verified against real Postgres with seeded rows.

The ReDoS defence I deliberately did not build

A user-supplied regex is normally a denial-of-service vector, and the reflex is to reject nested quantifiers. I measured instead. Postgres does not backtrack the way JavaScript does — (a+)+$, (a*)*$, (a|a)*$ and (a|aa)+$ all matched a 41-character subject in under a millisecond. The worst case found was a bounded state explosion, ^(a{1,100}){1,100}$, at 110ms, and Postgres rejects oversized repetition counts itself.

So that defence would have blocked legitimate patterns to guard a hole this database does not have. What's enforced is a 200-character pattern cap, on top of the indexed site_id+timestamp narrowing every filtered query already does. The residual risk is row multiplication — a query-cost problem, not a regex one — whose backstop is a database-level statement_timeout, verified to take effect on this pool and set in deployment rather than around individual queries, since manipulating connection state per query leaks settings between concurrent requests on a shared pool.

Syntax isn't pre-validated either: Postgres uses POSIX regex and JavaScript doesn't, so checking with new RegExp would reject valid patterns and accept invalid ones. Postgres is asked, and SQLSTATE 2201B becomes a 400 carrying its own message. Anything that isn't 2201B is rethrown — reporting our own broken SQL as a 400 would blame the user and hide the bug.

One deviation from the proposal: segments are per site, not "per user/site". A segment is a way of reading the site and colleagues benefit from each other's. It's also the reversible direction — adding privacy later is additive, whereas starting private and going shared changes who can see things people have already saved.

Verification. 25 checks against real Postgres. Five sabotages fail by name: negation dropping NULL rows, contains via LIKE, an endpoint bypassing the segment reader, dropping the cross-site scope on segment lookup, and swallowing every query error as a 400.

Also fixed a guard that broke on its own window: api-authz.test.ts sliced a fixed 900 characters from function topDimension( to check the auth gate, and growing the handler pushed .middleware('auth') out of it — the guard failed while the gate was intact, the kind of failure that gets "fixed" by raising the number until the next time. Now bounded by the helper's first registration.

Still open, deliberately: the k-anonymity floor. The interaction described in the comment above is now live — segments can narrow a population, and #21 funnels can then report a step count of 1. Nothing here suppresses that. It needs a product decision about what a suppressed cell looks like, because turning it on changes what every low-traffic filtered view returns, and that is not a call to make silently inside a feature commit. Filing it separately is the right next step.

9 days ago

Now filed properly as #40, with a reproduction rather than the hypothetical sketched above: 61 visitors narrowed to exactly 1 by two innocuous filters (country=IS + device=tablet), and the resulting funnel reporting that one person's complete journey.

#40 also records why it is not urgent — the funnel results endpoint takes no filters today, so the composition cannot be performed through the API in a single call. It should be settled before funnels accept segments, which is the obvious next request.

Sign in to comment on this issue.