Skip to content

feat(cohorts): anon-inclusive event cohorts — property→v2, name-only→raw events - #397

Open
ayushjhanwar-png wants to merge 1 commit into
mainfrom
feat/cohorts-anon-inclusive-v2
Open

feat(cohorts): anon-inclusive event cohorts — property→v2, name-only→raw events#397
ayushjhanwar-png wants to merge 1 commit into
mainfrom
feat/cohorts-anon-inclusive-v2

Conversation

@ayushjhanwar-png

@ayushjhanwar-png ayushjhanwar-png commented Jul 30, 2026

Copy link
Copy Markdown

Problem

Event-based cohorts silently undercount anonymous users. Both cohort MVs bake WHERE profile_id != device_id at write time, so pre-identify (anonymous) events never enter them.

Validated live on shortreels for a real property cohort (userFlags where reel-based-for-you=…, since 2026-07-12):

count
v1 (anon-excluded, old path) 13,862
v2 (anon-included, new path) 110,651

~8× audience was being dropped. The new query ran in 0.31s (635K rows read — property-pinned prefix scan).

Change

buildEventCriteriaQuery in cohort.service.ts:

  • Property criteria ("did X where flag=Y") → profile_event_property_summary_v2 (anon-inclusive), gated on the criterion's timeframe start ≥ COHORTS_V2_START_DATE (default 2026-07-01, env-overridable); older timeframes fall back to the v1 MV so we never read a partial/absent v2 range. Pure table-name swap — identical countMerge(event_count)/event_date aggregates. All projects, no allowlist.
  • Name-only criteria ("did X", any N×) → raw events (GROUP BY profile_id HAVING count() <op> / SELECT DISTINCT). Exact for any frequency, anon-inclusive, and faster than v2 (whose ARRAY JOIN explodes name-only reads → massive over-count on ≥N). Removes the only cohort read of cohort_events_mv.
  • New shared filter-operators.ts (operatorClause) used in the property branch. Fixes doesNotContain/startsWith/gt/regex/… silently falling through to IN.
  • Adds profile_event_property_summary_v2 to TABLE_NAMES.
  • Drops the dead filterClause chain.

.env.example: adds COHORTS_V2_START_DATE="2026-07-01".

Scope / notes

  • Self-contained on main — 5 files. (The profiles PR feat(profiles): Mixpanel-style profiles page — perf, filters, search #371 also adds the profile_event_property_summary_v2 key; when both merge there's a trivial one-line overlap on client.ts.)
  • cohort_events_mv table is kept — retention (trpc/chart.ts) + delete.service.ts still use it. This PR only stops cohorts depending on it; physically dropping it is a follow-up.
  • Out of scope: chart property-MV router, cohort-breakdown charts, retention. No new MV, no backfill.

Behaviour to expect

  • Anon-inclusive property cohorts grow (~8× on anon-heavy projects; ~unchanged on fully-identified events).
  • Membership recomputes on create / edit / manual refresh (no cron). Existing dynamic cohorts just need a Refresh — no recreation. computeOnDemand cohorts pick it up live and their count is not subject to the 10K membership cap.
  • The July-11 v2 gap (~80% coverage) still sits inside the default window — bump COHORTS_V2_START_DATE to 2026-07-12 to exclude it until the 07-11 re-backfill lands.

Tests / known debt

  • cohort.service.test.ts was non-compiling on main (stale frequency/timeframe shapes, invalid operators, wrong storeCohortMembership arity, a vitest mock-hoisting bug). Fixed those so it compiles + runs; the 8 routing tests now pass (computeEventBasedCohort/computePropertyBasedCohort/computeCohort).
  • 8 storage tests still fail (storeCohortMembership/getCohortMembers/getCohortCount/updateCohortMembership) — pre-existing drift in functions this PR does not touch: they mock chQuery while the real functions use ch.insert() / Postgres. Follow-up.
  • @openpanel/db typecheck is red on main for unrelated reasons (import.service, notification.service, reports.service) — not introduced here.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added configurable cohort rollout routing based on criterion timeframe dates.
    • Improved cohort event-property filtering with expanded operator support, including containment, pattern matching, null checks, and numeric comparisons.
    • Added support for routing eligible cohort queries to the updated event-property summary.
  • Bug Fixes
    • Corrected frequency-based event queries to use accurate event counts.
    • Improved handling of unsupported and negative filter operators.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Cohort property queries now route to v1 or v2 ClickHouse summary tables based on timeframe start dates, use shared operator SQL generation, and query raw events for name-only frequency criteria. Environment documentation, table mappings, mocks, fixtures, and assertions were updated.

Changes

Cohort query routing

Layer / File(s) Summary
Shared operator predicate generation
packages/db/src/services/filter-operators.ts
Adds operatorClause for escaped SQL predicates covering null checks, matching, regex, numeric comparisons, and membership operators.
Time-gated property table selection
.env.example, packages/db/src/clickhouse/client.ts, packages/db/src/services/cohort.service.ts
Adds the v2 table mapping and COHORTS_V2_START_DATE; property criteria select the v2 or v1 summary table from the criterion timeframe.
Query paths and test validation
packages/db/src/services/cohort.service.ts, packages/db/src/services/cohort.service.test.ts
Name-only frequency criteria query events with count(), while tests update mocks, fixtures, query assertions, and membership call arguments.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CohortCriteria
  participant buildEventCriteriaQuery
  participant ClickHouse
  CohortCriteria->>buildEventCriteriaQuery: event property criteria and timeframe
  buildEventCriteriaQuery->>buildEventCriteriaQuery: select v1 or v2 property summary table
  buildEventCriteriaQuery->>ClickHouse: execute property predicate query
Loading

Possibly related PRs

  • Dashverse/openpanel-forked#376: Both changes alter routing away from the v1 property materialized view toward corrected v2 or raw-event query behavior.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main cohort routing changes: property criteria to v2 and name-only criteria to raw events.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cohorts-anon-inclusive-v2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ayushjhanwar-png
ayushjhanwar-png changed the base branch from feat/profiles-mixpanel-perf to main July 30, 2026 14:28
…raw events

Event-based cohorts silently undercounted anonymous users: both cohort MVs
bake `WHERE profile_id != device_id` at write time, so pre-identify events
never entered them.

- Property criteria ("did X where flag=Y") → profile_event_property_summary_v2
  (anon-inclusive), gated on timeframe start >= COHORTS_V2_START_DATE (default
  2026-07-01, env-overridable); older timeframes fall back to the v1 MV. Pure
  table-name swap — same countMerge(event_count)/event_date aggregates.
- Name-only criteria ("did X", any N×) → raw events (GROUP BY profile_id
  HAVING count() <op> / SELECT DISTINCT). Exact for any frequency,
  anon-inclusive, faster than v2 (whose ARRAY JOIN explodes name-only reads).
  Removes the only cohort read of cohort_events_mv.
- New shared filter-operators.ts (operatorClause) used in the property branch —
  fixes doesNotContain/startsWith/gt/regex silently falling through to `IN`.
- Add profile_event_property_summary_v2 to TABLE_NAMES.
- Drop dead filterClause chain in buildEventCriteriaQuery.

Validated on shortreels: property cohort 13,862 (v1) → 110,651 (v2) in 0.3s.

cohort_events_mv table kept — retention (trpc/chart.ts) still reads it.
@ayushjhanwar-png
ayushjhanwar-png force-pushed the feat/cohorts-anon-inclusive-v2 branch from 9f47b7b to 0396bbd Compare July 30, 2026 14:34

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/db/src/services/cohort.service.ts (1)

114-187: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Route these new ClickHouse paths through the required query builder.

The v1/v2 table selection and raw-events branches continue constructing SQL templates directly. Refactor this path to use the custom query builder/query functions and ensure the execution path receives the timezone through clix(ch, tz); escaped literals reduce injection risk but do not satisfy the repository query contract.

As per coding guidelines, “ClickHouse queries must use the custom query builder at packages/db/src/clickhouse/query-builder.ts” and “pass the timezone into clix(ch, tz).”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/services/cohort.service.ts` around lines 114 - 187, The
cohort query branches around canRouteCohortToV2, the propertyTable path, and the
raw-events frequency/simple-event paths must stop returning interpolated SQL
templates directly. Refactor these queries to use the repository’s custom query
builder/query functions from query-builder.ts, preserving the existing table
selection, filters, frequency behavior, and aggregation semantics, and ensure
execution invokes clix(ch, tz) so the timezone is propagated.

Source: Coding guidelines

🧹 Nitpick comments (1)
packages/db/src/services/cohort.service.test.ts (1)

116-145: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exercise the property-filter route and assert the generated query.

This fixture uses name: 'path', so it takes the name-only raw-events branch; it does not cover the new properties.* routing. Use properties.path and assert the predicate/table, with cases on each side of the v1/v2 start-date gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/services/cohort.service.test.ts` around lines 116 - 145, The
test around computeEventBasedCohort should exercise the property-filter route by
changing the filter name to properties.path and asserting the generated query
includes the expected predicate and table. Add cases covering both sides of the
v1/v2 start-date gate, while preserving the existing cohort result assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.env.example:
- Around line 55-61: Update the COHORTS_V2_START_DATE default in .env.example
lines 55-61 from 2026-07-01 to 2026-07-12, and update the fallback default in
packages/db/src/services/cohort.service.ts line 20 to the same date. Keep the
routing behavior unchanged when an explicit configuration value is provided.

In `@packages/db/src/services/filter-operators.ts`:
- Around line 24-25: Update the doesNotContain branch in the filter operator
switch to join the generated NOT LIKE predicates with AND instead of OR,
ensuring every excluded value is absent while preserving the existing trimming,
escaping, and grouping behavior.

---

Outside diff comments:
In `@packages/db/src/services/cohort.service.ts`:
- Around line 114-187: The cohort query branches around canRouteCohortToV2, the
propertyTable path, and the raw-events frequency/simple-event paths must stop
returning interpolated SQL templates directly. Refactor these queries to use the
repository’s custom query builder/query functions from query-builder.ts,
preserving the existing table selection, filters, frequency behavior, and
aggregation semantics, and ensure execution invokes clix(ch, tz) so the timezone
is propagated.

---

Nitpick comments:
In `@packages/db/src/services/cohort.service.test.ts`:
- Around line 116-145: The test around computeEventBasedCohort should exercise
the property-filter route by changing the filter name to properties.path and
asserting the generated query includes the expected predicate and table. Add
cases covering both sides of the v1/v2 start-date gate, while preserving the
existing cohort result assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cdaeeca9-4719-44a0-a551-9ea353bc37ef

📥 Commits

Reviewing files that changed from the base of the PR and between 384647a and 0396bbd.

📒 Files selected for processing (5)
  • .env.example
  • packages/db/src/clickhouse/client.ts
  • packages/db/src/services/cohort.service.test.ts
  • packages/db/src/services/cohort.service.ts
  • packages/db/src/services/filter-operators.ts

Comment thread .env.example
Comment on lines +55 to +61
# Cohorts: property criteria ("did X where flag=Y") route to the anon-inclusive
# v2 property MV when the criterion's timeframe STARTS on/after this date (all
# projects — no allowlist); older timeframes fall back to the v1 anon-excluded
# MV. Name-only criteria always use raw events (source of truth, no gate).
# NOTE: the 07-11 v2 gap (~80% coverage) still sits inside this window — bump to
# 2026-07-12 to exclude it until the 07-11 re-backfill lands.
COHORTS_V2_START_DATE="2026-07-01"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not default routing into the documented partial-coverage window.

The July 11 v2 gap is explicitly documented as roughly 80% coverage, but both defaults remain 2026-07-01. Until the re-backfill is confirmed, this undercounts cohorts whose timeframe starts from July 1 through July 11.

  • .env.example#L55-L61: default COHORTS_V2_START_DATE to 2026-07-12.
  • packages/db/src/services/cohort.service.ts#L20-L20: change the fallback default to 2026-07-12 as well.
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 61-61: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)

📍 Affects 2 files
  • .env.example#L55-L61 (this comment)
  • packages/db/src/services/cohort.service.ts#L20-L20
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example around lines 55 - 61, Update the COHORTS_V2_START_DATE default
in .env.example lines 55-61 from 2026-07-01 to 2026-07-12, and update the
fallback default in packages/db/src/services/cohort.service.ts line 20 to the
same date. Keep the routing behavior unchanged when an explicit configuration
value is provided.

Comment on lines +24 to +25
case 'doesNotContain':
return `(${trimmed.map((v) => `${col} NOT LIKE ${esc(`%${v}%`)}`).join(' OR ')})`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Join multi-value doesNotContain predicates with AND.

NOT LIKE A OR NOT LIKE B admits values containing either excluded pattern. A “does not contain” filter must reject matches for all supplied values.

Proposed fix
-      return `(${trimmed.map((v) => `${col} NOT LIKE ${esc(`%${v}%`)}`).join(' OR ')})`;
+      return `(${trimmed.map((v) => `${col} NOT LIKE ${esc(`%${v}%`)}`).join(' AND ')})`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case 'doesNotContain':
return `(${trimmed.map((v) => `${col} NOT LIKE ${esc(`%${v}%`)}`).join(' OR ')})`;
case 'doesNotContain':
return `(${trimmed.map((v) => `${col} NOT LIKE ${esc(`%${v}%`)}`).join(' AND ')})`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/services/filter-operators.ts` around lines 24 - 25, Update
the doesNotContain branch in the filter operator switch to join the generated
NOT LIKE predicates with AND instead of OR, ensuring every excluded value is
absent while preserving the existing trimming, escaping, and grouping behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant