Skip to content

perf(db): route event-property-filter charts to profile_event_property_summary_mv - #356

Merged
ayushjhanwar-png merged 3 commits into
mainfrom
perf/chart-property-mv-routing
Jul 7, 2026
Merged

perf(db): route event-property-filter charts to profile_event_property_summary_mv#356
ayushjhanwar-png merged 3 commits into
mainfrom
perf/chart-property-mv-routing

Conversation

@ayushjhanwar-png

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

Copy link
Copy Markdown

Summary

  • Added a new MV routing branch to `chart.service.ts` that catches linear-chart queries with a single event-level property filter (e.g. `type='truecaller'`) and answers them from `profile_event_property_summary_mv` instead of the events table.
  • Rolling 92-day gate (~3 months + safety margin over the "3m" dashboard preset) — anything older falls through to the events-table path unchanged.
  • Result: dashreels 30-day `logIn+truecaller` query = 52.4s → 0.38s (~140× faster), verified end-to-end via local API against prod CH.

Why not the existing paths

Current linear-chart flow in `chart.service.ts`:

  1. `events_daily_stats` MV — used only when there are NO filters (schema: `project_id, name, date` — no property dimension)
  2. `events` table with query builder — everything else. Skip-index-driven pruning helps for columns with an index (`idx_country`, `idx_path`), but a filter on any column WITHOUT a skip index reads the full name-pruned slice and times out.

Concrete: `logIn + type='truecaller'` 30-day query took 52.4s direct (6.68 B rows, 152 GiB). `type` had no skip index and isn't in `proj_funnel`, so full-scan territory.

`profile_event_property_summary_mv` already exists and has EXACTLY the right sort key:
```
ORDER BY (project_id, name, property_key, property_value, profile_id, event_date)
```

Query hits the 4-column sort-key prefix → CH jumps straight to the tiny matching slice.

Measured (2026-07-07, dashreels, prod CH)

Read path Elapsed Rows read Bytes read
`events` + type filter (no skip idx) 52.4 s 6.68 B 152 GiB
`events` + country='US' (has skip idx) 7.9 s 2.26 B 11 GiB
`profile_event_property_summary_mv` (this PR) 0.38 s 1.4 M 128 MiB

Multi-property AND (`type='truecaller' AND $os='Android'`) via intersection JOIN: 0.39 s / 5.6 M rows / 457 MiB — basically same speed as single-property. Not shipped in this PR (deferred to follow-up); router only handles ONE filter for now.

Local prod smoke test

Ran API + dashboard locally against prod CH/DB. Loaded a linear chart on dashreels `logIn` with `properties.type = 'truecaller'` filter:

  • 7d range: MV route fired → 0.54s
  • 30d range: MV route fired → 0.15s
  • 3m range: MV route fired after the date-comparison bug fix (see below)
  • No filter (empty value): correctly falls through to events-table path

Bug fixed during smoke test

First cut used `formatClickhouseDate()` for the retention cutoff, returning full `YYYY-MM-DD HH:MM:SS`. Same-day startDate at midnight (`2026-04-07 00:00:00`) compared as less than a same-day cutoff at query time (`2026-04-07 22:XX:XX`) → gate rejected valid 3-month queries.

Fix: date-only comparison via `startDate.slice(0, 10)` + widened window to 92 days (safety margin over the 90-day "3m" preset).

Router gate (intentionally conservative)

  • ONE event-level property filter with `operator='is'` and non-empty value list
  • `filter.name` starts with `properties.` (dashboard's canonical form)
  • No breakdowns
  • No cohort filters (`inCohort`, `notInCohort`, `cohortId`)
  • Segment: `user` or `event` (MV has profile_id + countState; no session-uniq state)
  • Interval: `day` / `week` / `month`
  • Event name is explicit (not `*`)
  • `startDate.slice(0, 10) >= (today - 92 days)`

Anything outside falls through to the existing paths — zero behavior change for those queries.

What this doesn't cover (yet)

  • Multi-property AND — works via intersection JOIN, measured 0.39s in the same session. Deferred to next PR to keep MVP tight.
  • Property breakdown — needs JOIN with sibling MV rows, adds complexity.
  • Anonymous-heavy events (where `profile_id == device_id`) — MV excludes them; router only fires on event-property filters which typically target identified events (logIn, purchase, subscribeStart, etc.)
  • Conversion / funnel services — separate paths, follow-up PRs.

Doc dependency

`docs/conversion-chart-perf.md` was created in an earlier open PR (`#352`, `perf/conversion-fast-path-breakdown`). This PR includes it too because we're new-file-creating on both branches. When one merges, the other will conflict on file creation — trivial resolve, keep both sections.

Test plan

  • `pnpm --filter db typecheck` — no new errors in `chart.service.ts` (only pre-existing `getCohortMembershipSubquery` error at line 1842)
  • Local API + dashboard against prod CH — MV route confirmed firing for 7d/30d/3m ranges; falls through cleanly for empty-value filter
  • Numbers verified match ClickHouse direct-curl reference values
  • Reviewer: verify existing filter-free / cohort / breakdown queries still route through the correct existing paths (they should — gate is additive)
  • Reviewer: confirm the 92-day rolling window is acceptable; can widen or narrow if needed

Summary by CodeRabbit

  • New Features

    • Added a new faster materialized-view route for conversion chart queries when a single event-level property filter is present.
    • Enhanced conversion chart query generation to support exactly one event-level property breakdown while keeping results consistent.
  • Bug Fixes

    • Improved cohort/conversion query behavior to reduce unnecessary cohort work and address prior edge-case inconsistencies.
    • Added ref_utm_source as a materialized field on events to speed up conversion chart reads.
  • Documentation

    • Added/updated an internal performance investigation and rollout document covering conversion chart query paths on ClickHouse.

…y_summary_mv

Linear-chart queries with a single event-level property filter (e.g.
type='truecaller') fall to the events-table path today, which scans the
full name-pruned slice when the filter column has no skip index.
Measured on dashreels 30-day logIn+truecaller: 52.4s / 6.68 B rows /
152 GiB — over the 40s prod timeout.

profile_event_property_summary_mv is already sorted by
(project_id, name, property_key, property_value, profile_id, event_date)
— the 4-column prefix matches the filter shape exactly. Prefix-scan
answers the same query in 0.38s / 1.4 M rows / 128 MiB (~140x faster,
same numbers as events-table path).

Added canUsePropertyMV gate + getChartSqlFromPropertyMV builder to
chart.service.ts, sitting between the existing events_daily_stats route
and the events-table fallback. Gate is intentionally conservative:
one 'is' filter, no breakdowns, no cohort filters, user/event segment,
day/week/month interval, explicit event name, and startDate within the
MV's ~3-month retention window. Anything outside falls through
unchanged.

Retention cutoff is a rolling 92 days computed at query time (giving
~3 months + safety margin for the "3m" dashboard preset). Compares
date portion only (`startDate.slice(0, 10)`) — full DateTime
comparison rejected a same-day startDate against a later time-of-day
cutoff, caught during prod smoke test.

Multi-property AND (2+ filters) via intersection JOIN measured at 0.39s
in the same session; deferred to a follow-up PR. Conversion/funnel
services are separate paths — also follow-up.
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 05d9b98c-9360-49b4-a9c3-c150987181da

📥 Commits

Reviewing files that changed from the base of the PR and between c536a15 and dcc39e6.

📒 Files selected for processing (2)
  • docs/conversion-chart-perf.md
  • packages/db/src/services/chart.service.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/conversion-chart-perf.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/db/src/services/chart.service.ts

📝 Walkthrough

Walkthrough

This PR adds a new ClickHouse routing path for single event-property chart filters and expands the conversion chart performance document with routing details, shipped query changes, investigation findings, tests, and diagnostic SQL.

Changes

Conversion chart MV routing

Layer / File(s) Summary
Property-MV fast path
packages/db/src/services/chart.service.ts
Adds helpers to compute a rolling MV cutoff, extract property keys, validate routing eligibility, and generate SQL against profile_event_property_summary_mv, then wires the new route into getChartSql after the existing MV fast path.
Routing and shipped changes write-up
docs/conversion-chart-perf.md
Documents the current routing behavior, the filtered-chart slow path, the new property-MV gate, its cutoff and exclusions, and the 2026-07-04 ref_utm_source and split-scan breakdown changes.
Timeout root causes and shipped fixes
docs/conversion-chart-perf.md
Records the original timeout sources, the shipped cohort-event and cohort-filter fixes, the measured behavior changes, and the remaining load-related timeout issues including the agg duplication and max_execution_time consideration.
Updated findings and diagnostics
docs/conversion-chart-perf.md
Adds the later findings on the agg single-pass refactor, remaining load-sensitive timeout causes, volume breakdowns, proposed next steps, and diagnostic SQL snippets.

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

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant getChartSql
  participant canUsePropertyMV
  participant getChartSqlFromPropertyMV
  participant QueryBuilder

  Client->>getChartSql: request chart SQL
  getChartSql->>canUsePropertyMV: check single property filter eligibility
  alt eligible and not customEvent
    canUsePropertyMV-->>getChartSql: allowed
    getChartSql->>getChartSqlFromPropertyMV: build profile_event_property_summary_mv SQL
    getChartSqlFromPropertyMV-->>getChartSql: property MV SQL
  else not eligible
    getChartSql->>QueryBuilder: build standard chart SQL
    QueryBuilder-->>getChartSql: standard SQL
  end
  getChartSql-->>Client: SQL result
Loading

Related issues: None provided.

Related PRs: None provided.

Suggested labels: performance, clickhouse, documentation

Suggested reviewers: harshitsaamu

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: routing event-property-filter chart queries to the new materialized view.
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.
✨ 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 perf/chart-property-mv-routing

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

@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: 3

🧹 Nitpick comments (4)
packages/db/src/services/chart.service.ts (3)

557-568: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Triplicated day/week/month branching logic.

The interval-based ternary for date bucketing is repeated three times (dateSelect, dateGroupBy, fillClause). Consolidating into a small per-interval lookup/helper would reduce the chance of exactly the kind of inconsistency flagged above (e.g., one branch missing a parameter that the others have).

Also applies to: 580-585

🤖 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/chart.service.ts` around lines 557 - 568, The
interval-based date bucketing logic in chart.service.ts is duplicated across
dateSelect, dateGroupBy, and fillClause, which risks inconsistencies between
branches. Refactor this into a single helper or per-interval lookup keyed by the
interval value (day/week/month) and reuse it for all three expressions in the
chart aggregation flow so the date formatting, grouping, and fill behavior
always stay aligned.

602-604: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Full generated SQL (including escaped filter values) is logged to console on every property-MV query.

If a filter value carries PII (e.g., an email or identifier used as an event property), it will land in application logs verbatim. This mirrors the pre-existing console.log pattern elsewhere in this file, so it's consistent with current conventions, but worth reconsidering (e.g., gate behind a debug flag) since this is a new call site.

🤖 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/chart.service.ts` around lines 602 - 604, The new
console logging in chart.service.ts around the profile_event_property_summary_mv
SQL generation can leak PII because it prints the fully rendered query with
filter values. Update the logging at the sql.replaceAll call site to be guarded
by an explicit debug/trace flag (or remove it entirely) so only safe diagnostics
are emitted in normal execution, and keep the surrounding property-MV query flow
unchanged.

496-533: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Line 507 is unreachable dead code.

validSegments (line 505) only contains 'user' and 'event', so event.segment ?? 'event' failing that check (line 506) already rejects 'one_event_per_user' before line 507 is ever reached.

🧹 Proposed cleanup
   const validSegments = ['user', 'event'];
   if (!validSegments.includes(event.segment ?? 'event')) return false;
-  if (event.segment === 'one_event_per_user') return false;
🤖 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/chart.service.ts` around lines 496 - 533, The extra
`event.segment === 'one_event_per_user'` guard in `canUsePropertyMV` is
unreachable because the earlier `validSegments` check already excludes it;
remove the redundant branch and keep the segment validation centralized in
`canUsePropertyMV` alongside the existing `validSegments` logic.
docs/conversion-chart-perf.md (1)

18-18: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fenced code blocks missing a language identifier.

Flagged by markdownlint (MD040) at these four fenced blocks. Adding a language (e.g. sql, text) fixes lint and improves rendering.

Also applies to: 180-180, 188-188, 421-421

🤖 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 `@docs/conversion-chart-perf.md` at line 18, The markdown file has fenced code
blocks without a language identifier, triggering MD040. Update each affected
fenced block in the conversion chart document to include an appropriate language
tag such as sql or text so the linter passes and rendering improves; make the
same change for all four flagged fences, including the ones around the chart
examples and summary snippets.

Source: Linters/SAST tools

🤖 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 `@docs/conversion-chart-perf.md`:
- Around line 45-58: Update the gate description to match the dynamic rolling
cutoff used by getPropertyMVCutoffDate in chart.service.ts: replace the fixed
date language in the Router shipped section with a rolling 3-month cutoff
computed at query time. Keep the rest of the gate bullets unchanged, but
describe the date constraint in terms of now()-based coverage so the docs stay
accurate over time.

In `@packages/db/src/services/chart.service.ts`:
- Around line 551-555: The filter value handling in the chart service’s
property-filter builder is missing the same trimming used by the sibling MV
query path. Update the `filter.value` mapping in `chart.service.ts` to trim each
value before `sqlstring.escape`, matching `cohort.service.ts`’s
`profile_event_property_summary_mv` logic so both builders compare against the
same normalized values. Use the existing `filter.value!` mapping block as the
place to apply the trim consistently.
- Around line 535-568: Thread the timezone argument through both MV bucket
builders so they match the non-MV chart SQL. Update
getChartSqlFromMaterializedView and getChartSqlFromPropertyMV to use the passed
timezone in their week/month bucket expressions and fill-range logic instead of
hardcoding toStartOfWeek(..., 1) and the current month boundaries. Make sure the
relevant date grouping and bucket generation inside these helpers consistently
use timezone-aware conversions for non-UTC dashboards.

---

Nitpick comments:
In `@docs/conversion-chart-perf.md`:
- Line 18: The markdown file has fenced code blocks without a language
identifier, triggering MD040. Update each affected fenced block in the
conversion chart document to include an appropriate language tag such as sql or
text so the linter passes and rendering improves; make the same change for all
four flagged fences, including the ones around the chart examples and summary
snippets.

In `@packages/db/src/services/chart.service.ts`:
- Around line 557-568: The interval-based date bucketing logic in
chart.service.ts is duplicated across dateSelect, dateGroupBy, and fillClause,
which risks inconsistencies between branches. Refactor this into a single helper
or per-interval lookup keyed by the interval value (day/week/month) and reuse it
for all three expressions in the chart aggregation flow so the date formatting,
grouping, and fill behavior always stay aligned.
- Around line 602-604: The new console logging in chart.service.ts around the
profile_event_property_summary_mv SQL generation can leak PII because it prints
the fully rendered query with filter values. Update the logging at the
sql.replaceAll call site to be guarded by an explicit debug/trace flag (or
remove it entirely) so only safe diagnostics are emitted in normal execution,
and keep the surrounding property-MV query flow unchanged.
- Around line 496-533: The extra `event.segment === 'one_event_per_user'` guard
in `canUsePropertyMV` is unreachable because the earlier `validSegments` check
already excludes it; remove the redundant branch and keep the segment validation
centralized in `canUsePropertyMV` alongside the existing `validSegments` logic.
🪄 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: 99844ef4-ebd1-472b-ad50-eaede6cad4a7

📥 Commits

Reviewing files that changed from the base of the PR and between de02208 and 2e55846.

📒 Files selected for processing (2)
  • docs/conversion-chart-perf.md
  • packages/db/src/services/chart.service.ts

Comment thread docs/conversion-chart-perf.md
Comment on lines +535 to +568
function getChartSqlFromPropertyMV({
event,
interval,
startDate,
endDate,
projectId,
}: {
event: IGetChartDataInput['event'];
interval: IGetChartDataInput['interval'];
startDate: string;
endDate: string;
projectId: string;
timezone: string;
}): string {
const filter = event.filters![0]!;
const propKey = extractEventPropertyKey(filter.name)!;
// filter.value is already validated non-empty in the gate. Escape each
// value; sqlstring.escape handles ' and other SQL-special chars.
const valueList = filter.value!
.map((v) => sqlstring.escape(String(v)))
.join(', ');

const dateSelect =
interval === 'day'
? 'toStartOfDay(toDateTime(t.event_date)) as date'
: interval === 'week'
? 'toDateTime(toStartOfWeek(t.event_date, 1)) as date'
: 'toDateTime(toStartOfMonth(t.event_date)) as date';
const dateGroupBy =
interval === 'day'
? 'toStartOfDay(toDateTime(t.event_date))'
: interval === 'week'
? 'toDateTime(toStartOfWeek(t.event_date, 1))'
: 'toDateTime(toStartOfMonth(t.event_date))';

@coderabbitai coderabbitai Bot Jul 7, 2026

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and inspect the surrounding implementation.
wc -l packages/db/src/services/chart.service.ts
ast-grep outline packages/db/src/services/chart.service.ts --view expanded || true

# Read the relevant region around the reported lines.
sed -n '500,650p' packages/db/src/services/chart.service.ts

# Find other timezone-aware bucketing calls in this file.
rg -n "toStartOf(Week|Month|Day)|timezone" packages/db/src/services/chart.service.ts

Repository: Dashverse/openpanel-forked

Length of output: 11928


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the sibling materialized-view builder and the dispatcher that chooses between paths.
sed -n '323,430p' packages/db/src/services/chart.service.ts
printf '\n---\n'
sed -n '851,980p' packages/db/src/services/chart.service.ts
printf '\n---\n'
sed -n '1080,1175p' packages/db/src/services/chart.service.ts

# Show where getChartSqlFromPropertyMV is called.
rg -n "getChartSqlFromPropertyMV|getChartSqlFromMaterializedView|getPerUserChartSql" packages/db/src/services/chart.service.ts

Repository: Dashverse/openpanel-forked

Length of output: 12262


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '323,430p' packages/db/src/services/chart.service.ts
printf '\n---\n'
sed -n '851,980p' packages/db/src/services/chart.service.ts
printf '\n---\n'
sed -n '1080,1175p' packages/db/src/services/chart.service.ts

rg -n "getChartSqlFromPropertyMV|getChartSqlFromMaterializedView|getPerUserChartSql" packages/db/src/services/chart.service.ts

Repository: Dashverse/openpanel-forked

Length of output: 12262


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the MV schemas to see whether the date column is Date or DateTime.
rg -n "events_daily_stats|profile_event_property_summary_mv|MaterializedView|event_date|date\s+Date|date\s+DateTime" packages/db src . -g '!**/node_modules/**'

Repository: Dashverse/openpanel-forked

Length of output: 19219


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "events_daily_stats|profile_event_property_summary_mv|event_date|date\s+Date|date\s+DateTime" packages/db src . -g '!**/node_modules/**'

Repository: Dashverse/openpanel-forked

Length of output: 16251


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the MV schemas to see whether the date column is Date or DateTime.
rg -n "events_daily_stats|profile_event_property_summary_mv|event_date|date\s+Date|date\s+DateTime" packages/db src . -g '!**/node_modules/**'

Repository: Dashverse/openpanel-forked

Length of output: 16251


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the MV definitions and table schemas around the date columns.
sed -n '1,220p' packages/db/code-migrations/9-events-daily-stats.ts
printf '\n---\n'
sed -n '1,260p' packages/db/code-migrations/11-backfill-profile-event-property-v2.ts
printf '\n---\n'
sed -n '1,200p' packages/db/code-migrations/12-backfill-profile-event-summary-v2.ts
printf '\n---\n'
sed -n '364,470p' packages/db/src/clickhouse/migration.ts

Repository: Dashverse/openpanel-forked

Length of output: 25670


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' packages/db/code-migrations/9-events-daily-stats.ts
printf '\n---\n'
sed -n '1,260p' packages/db/code-migrations/11-backfill-profile-event-property-v2.ts
printf '\n---\n'
sed -n '1,200p' packages/db/code-migrations/12-backfill-profile-event-summary-v2.ts
printf '\n---\n'
sed -n '364,470p' packages/db/src/clickhouse/migration.ts

Repository: Dashverse/openpanel-forked

Length of output: 25670


🌐 Web query:

ClickHouse toStartOfWeek toStartOfMonth timezone Date DateTime semantics explicit timezone parameter

💡 Result:

In ClickHouse, date and time functions such as toStartOfWeek and toStartOfMonth generally support an optional timezone parameter to control the temporal context of the calculation [1][2]. Semantics and Usage: 1. Default Behavior: If the optional timezone parameter is omitted, ClickHouse uses the server's default timezone [1][2]. 2. Explicit Timezone: When provided, the timezone parameter (a string, e.g., 'UTC' or 'Europe/Amsterdam') ensures the rounding operation (to the start of the week or month) is performed relative to that specific timezone rather than the server's local time [3][1][2]. 3. Data Independence: ClickHouse stores DateTime values as unix timestamps, which are inherently UTC [4][5]. The timezone parameter in these functions is used to interpret the components (year, month, day, etc.) of that timestamp during the transformation [4]. Function Signatures: - toStartOfWeek(datetime, [mode], [timezone]): Rounds a date/datetime to the start of the week. The mode (optional) determines the first day of the week (default is 0), and the timezone (optional) specifies the temporal alignment [1][2]. - toStartOfMonth(datetime, [timezone]): Rounds a date/datetime to the beginning of the month [2]. When working with these functions, providing an explicit timezone is essential if your data represents events spanning multiple timezones and you need the results to align with local calendar definitions [4]. If the timezone is not specified, consistency with the server's local setting is assumed [1][2].

Citations:


Thread timezone through both MV bucket builders.
getChartSqlFromMaterializedView and getChartSqlFromPropertyMV accept timezone, but their week/month buckets and fill ranges still call toStartOfWeek(..., 1) / toStartOfMonth(...) without it. The non-MV path uses explicit timezone here, so non-UTC dashboards can land on different week/month boundaries.

🤖 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/chart.service.ts` around lines 535 - 568, Thread the
timezone argument through both MV bucket builders so they match the non-MV chart
SQL. Update getChartSqlFromMaterializedView and getChartSqlFromPropertyMV to use
the passed timezone in their week/month bucket expressions and fill-range logic
instead of hardcoding toStartOfWeek(..., 1) and the current month boundaries.
Make sure the relevant date grouping and bucket generation inside these helpers
consistently use timezone-aware conversions for non-UTC dashboards.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Caller sets session_timezone on the CH session; toStartOfWeek() runs in that TZ. Matches existing getChartSqlFromMaterializedView pattern — not a real bug

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "session_timezone|getChartSql\(" packages/db/src -g '!**/node_modules/**' -C3

Length of output: 8160


@ayushjhanwar-png Confirmed — packages/db/src/engine/fetch.ts passes session_timezone: plan.timezone in clickhouse_settings when calling getChartSql, and packages/db/src/clickhouse/query-builder.ts does the same for its own callers. Since toStartOfWeek/toStartOfMonth without an explicit timezone argument fall back to the session timezone, and this MV route follows the same established pattern as getChartSqlFromMaterializedView, there's no discrepancy here. Thanks for pointing that out — withdrawing this comment.


🧬 Code Graph Analysis Results

packages/db/src/services/chart.service.ts

Snippet (MV route week bucketing is toStartOfWeek(...) without explicit TZ argument)

Lines ~260-296

  } else if (interval === 'week') {
    sb.select.date = 'toDateTime(toStartOfWeek(t.date, 1)) as date';
    sb.groupBy.date = 'toDateTime(toStartOfWeek(t.date, 1))';
    sb.orderBy.date = 'toDateTime(toStartOfWeek(t.date, 1)) ASC';
  } else if (interval === 'month') {
    sb.select.date = 'toDateTime(toStartOfMonth(t.date)) as date';
    sb.groupBy.date = 'toDateTime(toStartOfMonth(t.date))';
    sb.orderBy.date = 'toDateTime(toStartOfMonth(t.date)) ASC';
  }

  // Build WITH FILL for date gaps — use DateTime to match the regular query fill format
  let fillClause = '';
  if (interval === 'day') {
    fillClause = `WITH FILL FROM toStartOfDay(toDateTime(${sqlstring.escape(startDate)})) TO toStartOfDay(toDateTime(${sqlstring.escape(endDate)})) STEP toIntervalDay(1)`;
  } else if (interval === 'week') {
    fillClause = `WITH FILL FROM toDateTime(toStartOfWeek(toDate(${sqlstring.escape(startDate)}), 1)) TO toDateTime(toStartOfWeek(toDate(${sqlstring.escape(endDate)}), 1)) STEP toIntervalWeek(1)`;
  } else if (interval === 'month') {
    fillClause = `WITH FILL FROM toDateTime(toStartOfMonth(toDate(${sqlstring.escape(startDate)}))) TO toDateTime(toStartOfMonth(toDate(${sqlstring.escape(endDate)}))) STEP toIntervalMonth(1)`;
  }

Snippet (non-MV per-user route explicitly passes TZ to toStartOfWeek(..., ..., '${timezone}'))

Lines ~520-575

    case 'week':
      dateExpr = `toStartOfWeek(created_at, 1, '${timezone}')`;
      fill = `WITH FILL FROM toStartOfWeek(toDateTime('${startDate}'), 1, '${timezone}') TO toStartOfWeek(toDateTime('${endDate}'), 1, '${timezone}') STEP toIntervalWeek(1)`;
      break;

Snippet (property MV route week bucketing is also toStartOfWeek(...) without explicit TZ argument)

Lines ~180-230 (inside getChartSqlFromPropertyMV)

  const dateSelect =
    interval === 'day'
      ? 'toStartOfDay(toDateTime(t.event_date)) as date'
      : interval === 'week'
        ? 'toDateTime(toStartOfWeek(t.event_date, 1)) as date'
        : 'toDateTime(toStartOfMonth(t.event_date)) as date';
  const dateGroupBy =
    interval === 'day'
      ? 'toStartOfDay(toDateTime(t.event_date))'
      : interval === 'week'
        ? 'toDateTime(toStartOfWeek(t.event_date, 1))'
        : 'toDateTime(toStartOfMonth(t.event_date))';

  const fillClause =
    interval === 'day'
      ? `WITH FILL FROM toStartOfDay(toDateTime(${sqlstring.escape(startDate)})) TO toStartOfDay(toDateTime(${sqlstring.escape(endDate)})) STEP toIntervalDay(1)`
      : interval === 'week'
        ? `WITH FILL FROM toDateTime(toStartOfWeek(toDate(${sqlstring.escape(startDate)}), 1)) TO toDateTime(toStartOfWeek(toDate(${sqlstring.escape(endDate)}), 1)) STEP toIntervalWeek(1)`
        : `WITH FILL FROM toStartOfMonth(toDate(${sqlstring.escape(startDate)}))) TO toStartOfMonth(toDate(${sqlstring.escape(endDate)}))) STEP toIntervalMonth(1)`;

Comment thread packages/db/src/services/chart.service.ts Outdated
ayushjhanwar-png and others added 2 commits July 7, 2026 16:06
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
- trim filter values before sqlstring.escape to match the sibling MV
  builder in cohort.service.ts. Prevents silent under-matching when a
  dashboard value carries incidental whitespace.
- remove unreachable one_event_per_user guard in canUsePropertyMV — the
  validSegments check ('user' | 'event') already rejects it.
- update doc gate description to describe the rolling window instead of
  the compile-time constant that was renamed away from earlier in the
  same doc.
- add language tags (sql / text) to fenced code blocks flagged by
  markdownlint MD040.

Skipped:
- Timezone threading in week/month builders. Caller sets
  session_timezone via clickhouse_settings; toStartOfWeek etc. run in
  that session TZ. Matches existing getChartSqlFromMaterializedView
  pattern.
- Guarding console.log behind a debug flag. Existing
  getChartSqlFromMaterializedView uses the same unguarded log; changing
  one and not the other creates inconsistency. Separate cleanup.
- Extracting per-interval helper for date bucketing. Existing MV
  builder in this same file uses the same triplicated shape; refactor
  is out of scope for this PR.
@ayushjhanwar-png
ayushjhanwar-png merged commit a1d884f into main Jul 7, 2026
5 checks passed
ayushjhanwar-png added a commit that referenced this pull request Jul 7, 2026
…357)

* perf(db): extend property-MV router to breakdown-by-property charts

Follow-up to PR #356. Same MV, same sort-key prefix trick — but this
time the query hits (project_id, name, property_key) as the 3-column
prefix and returns property_value as the breakdown b_0 dimension.

Prod measurement (dashreels, 30-day logIn breakdown by properties.type):
  events-table path: not tested (would be ~50s+ range based on filter case)
  this route:        0.10s / 4.16 M rows / 271 MiB

Real signal returned across 7 login types (otpless dominant at 67K/day
Jun 7, deviceId 35K, truecaller 32K, then anonymous/google/sms/apple
trailing). Matches numbers from the single-filter test path exactly.

Gate change: canUsePropertyMV now accepts XOR(filter, breakdown) — at
most one property is bound. Both filter+breakdown (needs intersection
JOIN) and multi-filter/multi-breakdown stay deferred to the future
multi-property PR. Cohort breakdowns rejected — they need cohort
membership joins, not this MV.

SQL builder change: emits `property_value AS b_0` in SELECT, adds b_0
to GROUP BY, and interleaves WITH FILL inside the ORDER BY (between the
date and b_0 columns) — trailing WITH FILL on a String column errors
out with INVALID_WITH_FILL_EXPRESSION on CH 25.3+ because it tries to
fill b_0 with a day step. Caught by smoke test before push.

MV filters `property_value != ''` on write, so the breakdown result
omits events with empty values under the breakdown key. Events-table
path would show them as a '' bucket. Documented as a semantic caveat
in the SQL builder comment.

* fix(chart): emit label_1 (not b_0) for breakdown value in property-MV route

Prod smoke test caught the frontend collapsing all breakdown values into
a single "(Not set)" bucket. Root cause: I used b_0 (conversion service
convention) for the breakdown column alias. The linear/bar chart
frontend calls groupByLabels which expects the standard
{label_0, [label_1], date, count} shape — label_0 for the event name
and label_1..N for breakdown values. b_0 is what conversion.service.ts
uses because its dense_rank top-N wrap needs a distinct namespace.

Rename all b_0 references in the property-MV builder to label_1. Also
updated the WITH FILL interleave to match. Conversion service still
uses b_0 (correctly, since it's a different result shape); nothing else
was touched by the replace.

* docs: document anonymous-heavy event safety boundary

Both MVs the router uses filter `profile_id != device_id` on write, so
anonymous events (installReferrer, first_open, deepLinkCaptured, etc.)
are excluded. Filtered/breakdown charts on those events would
under-count by ~100x.

Not a bug in practice — the dashboard usage pattern doesn't filter or
break down anonymous-heavy events (they don't carry `type`, `plan`, etc.
that filters target). Empirically verified on dashreels 2026-07-07:
installReferrer had 122K events in raw, only 405 in the MV (99.4%
anonymous, all excluded).

Documented with the empirical numbers + escape hatch (add to a blacklist
in canUsePropertyMV) so future maintainers hitting this shape have the
context.

* fix(chart): per-series WITH FILL layout for breakdown property-MV route

Original ORDER BY put the date column first with WITH FILL and label_1
as a secondary sort. WITH FILL applies to the column it directly
precedes, so this filled the date sequence globally — gap-day rows
generated by the fill couldn't be attributed to a specific label_1
series, breaking per-series timeline continuity in the frontend.

Fix: `ORDER BY label_1 ASC, date ASC WITH FILL FROM ... STEP ...`. CH
sorts within each label_1 group by date, then fills date gaps
per-group. Each breakdown series gets its own contiguous filled
timeline. Verified via A/B smoke test — same result count when no gaps
present, correct per-series output when gaps exist.

Caught by CodeRabbit review.
ayushjhanwar-png added a commit that referenced this pull request Jul 7, 2026
Follow-up to PR #356. Same MV, same sort-key prefix trick — but this
time the query hits (project_id, name, property_key) as the 3-column
prefix and returns property_value as the breakdown b_0 dimension.

Prod measurement (dashreels, 30-day logIn breakdown by properties.type):
  events-table path: not tested (would be ~50s+ range based on filter case)
  this route:        0.10s / 4.16 M rows / 271 MiB

Real signal returned across 7 login types (otpless dominant at 67K/day
Jun 7, deviceId 35K, truecaller 32K, then anonymous/google/sms/apple
trailing). Matches numbers from the single-filter test path exactly.

Gate change: canUsePropertyMV now accepts XOR(filter, breakdown) — at
most one property is bound. Both filter+breakdown (needs intersection
JOIN) and multi-filter/multi-breakdown stay deferred to the future
multi-property PR. Cohort breakdowns rejected — they need cohort
membership joins, not this MV.

SQL builder change: emits `property_value AS b_0` in SELECT, adds b_0
to GROUP BY, and interleaves WITH FILL inside the ORDER BY (between the
date and b_0 columns) — trailing WITH FILL on a String column errors
out with INVALID_WITH_FILL_EXPRESSION on CH 25.3+ because it tries to
fill b_0 with a day step. Caught by smoke test before push.

MV filters `property_value != ''` on write, so the breakdown result
omits events with empty values under the breakdown key. Events-table
path would show them as a '' bucket. Documented as a semantic caveat
in the SQL builder comment.
ayushjhanwar-png added a commit that referenced this pull request Jul 7, 2026
Follow-up to PR #356. Same MV, same sort-key prefix trick — but this
time the query hits (project_id, name, property_key) as the 3-column
prefix and returns property_value as the breakdown b_0 dimension.

Prod measurement (dashreels, 30-day logIn breakdown by properties.type):
  events-table path: not tested (would be ~50s+ range based on filter case)
  this route:        0.10s / 4.16 M rows / 271 MiB

Real signal returned across 7 login types (otpless dominant at 67K/day
Jun 7, deviceId 35K, truecaller 32K, then anonymous/google/sms/apple
trailing). Matches numbers from the single-filter test path exactly.

Gate change: canUsePropertyMV now accepts XOR(filter, breakdown) — at
most one property is bound. Both filter+breakdown (needs intersection
JOIN) and multi-filter/multi-breakdown stay deferred to the future
multi-property PR. Cohort breakdowns rejected — they need cohort
membership joins, not this MV.

SQL builder change: emits `property_value AS b_0` in SELECT, adds b_0
to GROUP BY, and interleaves WITH FILL inside the ORDER BY (between the
date and b_0 columns) — trailing WITH FILL on a String column errors
out with INVALID_WITH_FILL_EXPRESSION on CH 25.3+ because it tries to
fill b_0 with a day step. Caught by smoke test before push.

MV filters `property_value != ''` on write, so the breakdown result
omits events with empty values under the breakdown key. Events-table
path would show them as a '' bucket. Documented as a semantic caveat
in the SQL builder comment.
ayushjhanwar-png added a commit that referenced this pull request Jul 7, 2026
…redo) (#359)

* perf(db): extend property-MV router to breakdown-by-property charts

Follow-up to PR #356. Same MV, same sort-key prefix trick — but this
time the query hits (project_id, name, property_key) as the 3-column
prefix and returns property_value as the breakdown b_0 dimension.

Prod measurement (dashreels, 30-day logIn breakdown by properties.type):
  events-table path: not tested (would be ~50s+ range based on filter case)
  this route:        0.10s / 4.16 M rows / 271 MiB

Real signal returned across 7 login types (otpless dominant at 67K/day
Jun 7, deviceId 35K, truecaller 32K, then anonymous/google/sms/apple
trailing). Matches numbers from the single-filter test path exactly.

Gate change: canUsePropertyMV now accepts XOR(filter, breakdown) — at
most one property is bound. Both filter+breakdown (needs intersection
JOIN) and multi-filter/multi-breakdown stay deferred to the future
multi-property PR. Cohort breakdowns rejected — they need cohort
membership joins, not this MV.

SQL builder change: emits `property_value AS b_0` in SELECT, adds b_0
to GROUP BY, and interleaves WITH FILL inside the ORDER BY (between the
date and b_0 columns) — trailing WITH FILL on a String column errors
out with INVALID_WITH_FILL_EXPRESSION on CH 25.3+ because it tries to
fill b_0 with a day step. Caught by smoke test before push.

MV filters `property_value != ''` on write, so the breakdown result
omits events with empty values under the breakdown key. Events-table
path would show them as a '' bucket. Documented as a semantic caveat
in the SQL builder comment.

* fix(chart): emit label_1 (not b_0) for breakdown value in property-MV route

Prod smoke test caught the frontend collapsing all breakdown values into
a single "(Not set)" bucket. Root cause: I used b_0 (conversion service
convention) for the breakdown column alias. The linear/bar chart
frontend calls groupByLabels which expects the standard
{label_0, [label_1], date, count} shape — label_0 for the event name
and label_1..N for breakdown values. b_0 is what conversion.service.ts
uses because its dense_rank top-N wrap needs a distinct namespace.

Rename all b_0 references in the property-MV builder to label_1. Also
updated the WITH FILL interleave to match. Conversion service still
uses b_0 (correctly, since it's a different result shape); nothing else
was touched by the replace.

* fix(chart): per-series WITH FILL layout for breakdown property-MV route

Original ORDER BY put the date column first with WITH FILL and label_1
as a secondary sort. WITH FILL applies to the column it directly
precedes, so this filled the date sequence globally — gap-day rows
generated by the fill couldn't be attributed to a specific label_1
series, breaking per-series timeline continuity in the frontend.

Fix: `ORDER BY label_1 ASC, date ASC WITH FILL FROM ... STEP ...`. CH
sorts within each label_1 group by date, then fills date gaps
per-group. Each breakdown series gets its own contiguous filled
timeline. Verified via A/B smoke test — same result count when no gaps
present, correct per-series output when gaps exist.

Caught by CodeRabbit review.
ayushjhanwar-png added a commit that referenced this pull request Jul 16, 2026
)

* Revert "perf(db): extend property-MV router to breakdown-by-property charts (redo) (#359)"

This reverts commit 8213214.

* Revert "safety: gate property-MV route on explicit project allowlist (#358)"

This reverts commit 5f9a286.

* Revert "perf(db): route event-property-filter charts to profile_event_property_summary_mv (#356)"

This reverts commit a1d884f.
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