Skip to content

feat: Add filters support for Goals & Funnels, goal conditions, and conversion insights#538

Merged
Blaumaus merged 4 commits into
mainfrom
feature/goals-funnels-improvements
May 21, 2026
Merged

feat: Add filters support for Goals & Funnels, goal conditions, and conversion insights#538
Blaumaus merged 4 commits into
mainfrom
feature/goals-funnels-improvements

Conversation

@Blaumaus
Copy link
Copy Markdown
Member

@Blaumaus Blaumaus commented May 20, 2026

Changes

If applicable, please describe what changes were made in this pull request.

Community Edition support

  • Your feature is implemented for the Swetrix Community Edition
  • This PR only updates the Cloud (Enterprise) Edition code (e.g. Paddle webhooks, blog, payouts, etc.)

Database migrations

  • Clickhouse / MySQL migrations added for this PR
  • No table schemas changed in this PR

Documentation

  • You have updated the documentation according to your PR
  • This PR did not change any publicly documented endpoints

Summary by CodeRabbit

  • New Features

    • Apply custom filters to funnel and goal analytics; view per-step conversion breakdowns (countries, devices, browsers, sources, campaigns, pages, profile types) and conversion time metrics (avg/median/p75).
    • Create and edit multi-condition goals (AND/OR) with nested conditions; goal stats/chart honor filters.
    • Toggle "dropoff-only" sessions in funnel sessions drawer; sessions drawer and funnel UI updated with filter controls.
  • Documentation

    • Updated funnels, goals, and API docs/examples to cover filters, multi-condition goals, breakdowns, dropoff behavior, and time-to-convert.

Review Change Stack

@Blaumaus Blaumaus self-assigned this May 20, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

Caution

Review failed

Failed to post review comments

📝 Walkthrough

Walkthrough

Adds analytics filters support and multi-condition goals end-to-end: DTOs/interfaces updated; analytics services/controllers compute session-scoped funnels with multi-dimension breakdowns and time-to-convert; goal entities, DTOs, controllers and services persist and apply conditions; frontend API/types, UI (filters, condition editor, sessions drawer) and routes updated; migrations and docs added.

Changes

Full PR checkpoint

Layer / File(s) Summary
All changes: DTOs, interfaces, services, controllers, frontend, migrations, docs
multiple files across backend/apps/{cloud,community}, web/app/*, docs/*, backend/migrations/*
Adds optional filters and dropoff to funnel endpoints; expands funnel steps to include breakdowns (countries, devices, browsers, sources, campaigns, pages, profileTypes) and timeToConvert; threads session-scoped filtersQuery into ClickHouse funnel/session queries; adds multi-condition goal model/DTOs and persistence (conditions JSON); builds condition-expression and conversions-subquery pipeline for goal stats/charts (with breakdowns and time-to-convert); updates frontend API types, goal settings modal (condition editor), filter row editor, funnels/goals views, sessions drawer dropoff toggle, route actions and proxy passthroughs; adds ClickHouse/MySQL migrations for goal.conditions; updates docs and translations.

Estimated code review effort:
🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs:

  • Swetrix/swetrix#512: Prior work on funnel-sessions enhancements that this PR extends with filters and dropoff semantics.
  • Swetrix/swetrix#511: Related changes to sessions drawer and funnel session handling in the UI.
  • Swetrix/swetrix#446: Overlapping goal-analytics work touching goal stats/chart pipelines.

Poem:

I'm a rabbit in the code, nibbling through the night,
I threaded filters softly till the funnels shone bright,
I planted goal conditions in JSON so neat,
I leapt through UIs and docs with tiny hopping feet,
Hooray — metrics now bloom, quick as a carrot bite!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main changes: adding filters support for Goals & Funnels, goal conditions, and conversion insights.
Description check ✅ Passed The PR description follows the required template with all checkboxes properly marked: Community Edition support checked, database migrations checked, and documentation updated checked.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 feature/goals-funnels-improvements

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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (1)
web/app/pages/Project/View/components/FilterRowsEditor.tsx (1)

416-418: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reconcile invalid operators when hydrating initial filters.

Line 416 loads rows from initialFilters without validating operator compatibility for the selected column. If persisted filters contain an operator now disallowed for that column (e.g., restricted metadata-key columns), the operator dropdown can render in an invalid state until the column is manually changed.

🔧 Proposed fix
   useEffect(() => {
     if (!active) {
       return
     }

-    const nextRows = filtersToRows(initialFilters)
+    const nextRows = filtersToRows(initialFilters).map((row) => {
+      const operatorOptions = getOperatorsForColumn(row.column)
+      const operator = operatorOptions.some(
+        (option) => option.value === row.operator,
+      )
+        ? row.operator
+        : 'is'
+      return { ...row, operator }
+    })
     setFilterRows(nextRows.length ? nextRows : [createEmptyFilterRow()])
   }, [active, initialFilters, resetKey])
🤖 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 `@web/app/pages/Project/View/components/FilterRowsEditor.tsx` around lines 416
- 418, When hydrating initialFilters into rows the code path (filtersToRows
followed by setFilterRows) does not validate that each row.operator is allowed
for its selected column, causing the operator dropdown to show invalid values;
update filtersToRows (or add a reconciliation step before calling setFilterRows)
to, for every row produced from initialFilters, compute the allowed operator
list for that row's column (use the same logic that populates the operator
dropdown), and if row.operator is not in that allowed list replace it with a
safe fallback (e.g., the first allowed operator or a canonical default like
"equals") or clear it; ensure this fix touches filtersToRows,
createEmptyFilterRow usage, and the hydration block that calls setFilterRows so
hydrated rows always have compatible operators.
🧹 Nitpick comments (4)
backend/apps/community/src/goal/goal.controller.ts (2)

635-641: 💤 Low value

Same SQL interpolation concern as cloud controller.

Direct interpolation of PROFILE_PREFIX_USER constant in SQL string.

♻️ Suggested refactor using parameter
-        SELECT 'profileTypes' AS kind, if(startsWith(profileId, '${AnalyticsService.PROFILE_PREFIX_USER}'), 'identified', 'anonymous') AS val, count() AS cnt
+        SELECT 'profileTypes' AS kind, if(startsWith(profileId, {profilePrefixUser:String}), 'identified', 'anonymous') AS val, count() AS cnt

Then add profilePrefixUser: AnalyticsService.PROFILE_PREFIX_USER to the query params.

🤖 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 `@backend/apps/community/src/goal/goal.controller.ts` around lines 635 - 641,
The SQL currently interpolates AnalyticsService.PROFILE_PREFIX_USER directly
into the query string (in the UNION SELECT using startsWith(profileId,
'${AnalyticsService.PROFILE_PREFIX_USER}')), which risks injection and makes
tests harder; change the SQL to use a parameter placeholder for the profile
prefix (e.g. startsWith(profileId, {profilePrefixUser}) or the query-param
syntax your DB client uses) and add profilePrefixUser:
AnalyticsService.PROFILE_PREFIX_USER to the query parameters passed to the
method that executes the query (refer to the query string and the call site
around the UNION SELECT in goal.controller.ts and the
AnalyticsService.PROFILE_PREFIX_USER symbol).

361-554: ⚖️ Poor tradeoff

Consider extracting shared condition-building logic.

The condition expression building, goal event condition, and conversions subquery logic is duplicated between cloud and community controllers (~200 lines). Consider extracting this into a shared utility or base class to reduce maintenance burden.

🤖 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 `@backend/apps/community/src/goal/goal.controller.ts` around lines 361 - 554,
The controller duplicates ~200 lines of condition and query building across
buildConditionExpression, buildGoalEventCondition, and
buildGoalConversionsSubquery; extract this logic into a shared module or base
class (e.g., GoalConditionBuilder with methods
buildConditionExpression(condition,index), buildGoalEventCondition(goal),
buildGoalConversionsSubquery(goal,filtersQuery)) and replace the duplicated
implementations in both cloud and community controllers by delegating to that
single implementation; ensure the shared helper returns the same shapes
({condition, params} and {query, params}), preserve use of
GOAL_CONDITION_COLUMNS and GoalType/Goal interfaces by importing them into the
new module, and update callers to merge params exactly as existing code does.
backend/apps/cloud/src/goal/goal.controller.ts (1)

715-721: 💤 Low value

Avoid direct string interpolation in SQL queries.

The PROFILE_PREFIX_USER constant is directly interpolated into the SQL string. While this is a static constant and currently safe, it's better practice to use parameterized queries consistently to prevent future refactoring from inadvertently introducing SQL injection risks.

♻️ Suggested refactor using parameter
-        SELECT 'profileTypes' AS kind, if(startsWith(profileId, '${AnalyticsService.PROFILE_PREFIX_USER}'), 'identified', 'anonymous') AS val, count() AS cnt
+        SELECT 'profileTypes' AS kind, if(startsWith(profileId, {profilePrefixUser:String}), 'identified', 'anonymous') AS val, count() AS cnt

Then add profilePrefixUser: AnalyticsService.PROFILE_PREFIX_USER to the query params.

🤖 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 `@backend/apps/cloud/src/goal/goal.controller.ts` around lines 715 - 721, The
SQL currently interpolates AnalyticsService.PROFILE_PREFIX_USER directly into
the query string (in the SELECT branch querying session_info using
startsWith(profileId, '...')), which risks future injection; change the SQL to
use a parameter placeholder instead of string interpolation for the profile
prefix in the startsWith call, and add a corresponding param (e.g.
profilePrefixUser: AnalyticsService.PROFILE_PREFIX_USER) to the query parameters
passed where the query is executed so the startsWith(profileId, <param>) is
provided safely; ensure you update the query invocation in goal.controller.ts to
include the new profilePrefixUser param.
backend/apps/community/src/analytics/analytics.service.ts (1)

1761-1777: ⚡ Quick win

Restrict session_info to the funnel cohort.

This CTE currently aggregates every session in the requested time range and only throws most of them away in the later join with expanded. On busy projects that makes breakdown requests scale with total traffic, not with the actual funnel cohort. Filtering session_info by funnel_sessions here would keep the query much cheaper.

🤖 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 `@backend/apps/community/src/analytics/analytics.service.ts` around lines 1761
- 1777, The session_info CTE is aggregating all sessions in the time range and
should be restricted to the funnel cohort to avoid scaling with total traffic;
modify the session_info definition in analytics.service.ts so it only includes
sessions present in the funnel cohort (e.g., add a WHERE psid IN (SELECT psid
FROM funnel_sessions) or INNER JOIN funnel_sessions USING (psid) before the
GROUP BY) so subsequent joins (like with expanded) operate only on
funnel_sessions.
🤖 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 `@backend/apps/cloud/src/analytics/analytics.service.ts`:
- Around line 1908-1911: The code assigns untrusted keys into a plain object
(details[type][step][row.val] = row.cnt), which allows prototype pollution if
row.val is "__proto__", "constructor", etc.; change the implementation so that
the bucket objects are created with a null prototype (e.g., use
Object.create(null) whenever initializing details[type] or details[type][step])
or validate/whitelist row.val before assignment and skip or normalize reserved
keys; locate the creation/initialization of details[type] and
details[type][step] and replace new plain {} with null-prototype objects or add
a guard that rejects keys like "__proto__", "constructor", "prototype" prior to
writing.

In `@backend/apps/community/src/analytics/analytics.service.ts`:
- Around line 2083-2132: The funnel timestamps are taken from global minIf/maxIf
and can mismatch the actual matched funnel run; update funnel_sessions to
extract the matched-run timestamps (firstStepAt and conversionAt) from the same
ordered event sequence used by windowFunnel (e.g. compute an ordered array of
events for each psid, use the windowFunnel result to pick the specific
indices/timestamps for the matching run instead of minIf/maxIf) so firstStepAt
and conversionAt pair correctly; also change session_starts (and first_pages
lookup) to derive sessionStart and firstPageAt scoped to the visit that contains
the matched funnel run (filter sessions/events by the matched-run time window
per psid) rather than using min(firstSeen) across all history. Ensure you update
references in funnel_sessions, session_starts and first_pages to use the
matched-run boundaries.

In `@web/app/routes/projects`.$id.tsx:
- Around line 700-705: The code unguardedly calls JSON.parse on form values
(metadataFilters and conditions, and the other parsing at lines ~739-744) which
will throw on invalid input; wrap each JSON.parse in a try/catch or use a small
safeParse helper (e.g., safeJsonParse(value, default) or try { parsed =
JSON.parse(...) } catch { return new Response('Invalid JSON in <field>', {
status: 400 }) }) to return a controlled 400 instead of letting the action throw
a 500, and also validate the parsed type (ensure metadataFilters is an array,
conditions is the expected object/null) before continuing; update the parsing
sites that reference metadataFilters and conditions to use this guarded parsing.

---

Outside diff comments:
In `@web/app/pages/Project/View/components/FilterRowsEditor.tsx`:
- Around line 416-418: When hydrating initialFilters into rows the code path
(filtersToRows followed by setFilterRows) does not validate that each
row.operator is allowed for its selected column, causing the operator dropdown
to show invalid values; update filtersToRows (or add a reconciliation step
before calling setFilterRows) to, for every row produced from initialFilters,
compute the allowed operator list for that row's column (use the same logic that
populates the operator dropdown), and if row.operator is not in that allowed
list replace it with a safe fallback (e.g., the first allowed operator or a
canonical default like "equals") or clear it; ensure this fix touches
filtersToRows, createEmptyFilterRow usage, and the hydration block that calls
setFilterRows so hydrated rows always have compatible operators.

---

Nitpick comments:
In `@backend/apps/cloud/src/goal/goal.controller.ts`:
- Around line 715-721: The SQL currently interpolates
AnalyticsService.PROFILE_PREFIX_USER directly into the query string (in the
SELECT branch querying session_info using startsWith(profileId, '...')), which
risks future injection; change the SQL to use a parameter placeholder instead of
string interpolation for the profile prefix in the startsWith call, and add a
corresponding param (e.g. profilePrefixUser:
AnalyticsService.PROFILE_PREFIX_USER) to the query parameters passed where the
query is executed so the startsWith(profileId, <param>) is provided safely;
ensure you update the query invocation in goal.controller.ts to include the new
profilePrefixUser param.

In `@backend/apps/community/src/analytics/analytics.service.ts`:
- Around line 1761-1777: The session_info CTE is aggregating all sessions in the
time range and should be restricted to the funnel cohort to avoid scaling with
total traffic; modify the session_info definition in analytics.service.ts so it
only includes sessions present in the funnel cohort (e.g., add a WHERE psid IN
(SELECT psid FROM funnel_sessions) or INNER JOIN funnel_sessions USING (psid)
before the GROUP BY) so subsequent joins (like with expanded) operate only on
funnel_sessions.

In `@backend/apps/community/src/goal/goal.controller.ts`:
- Around line 635-641: The SQL currently interpolates
AnalyticsService.PROFILE_PREFIX_USER directly into the query string (in the
UNION SELECT using startsWith(profileId,
'${AnalyticsService.PROFILE_PREFIX_USER}')), which risks injection and makes
tests harder; change the SQL to use a parameter placeholder for the profile
prefix (e.g. startsWith(profileId, {profilePrefixUser}) or the query-param
syntax your DB client uses) and add profilePrefixUser:
AnalyticsService.PROFILE_PREFIX_USER to the query parameters passed to the
method that executes the query (refer to the query string and the call site
around the UNION SELECT in goal.controller.ts and the
AnalyticsService.PROFILE_PREFIX_USER symbol).
- Around line 361-554: The controller duplicates ~200 lines of condition and
query building across buildConditionExpression, buildGoalEventCondition, and
buildGoalConversionsSubquery; extract this logic into a shared module or base
class (e.g., GoalConditionBuilder with methods
buildConditionExpression(condition,index), buildGoalEventCondition(goal),
buildGoalConversionsSubquery(goal,filtersQuery)) and replace the duplicated
implementations in both cloud and community controllers by delegating to that
single implementation; ensure the shared helper returns the same shapes
({condition, params} and {query, params}), preserve use of
GOAL_CONDITION_COLUMNS and GoalType/Goal interfaces by importing them into the
new module, and update callers to merge params exactly as existing code does.
🪄 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

Run ID: ad88676b-d73d-4401-96ca-04693c9635aa

📥 Commits

Reviewing files that changed from the base of the PR and between d9e053f and 35a50e9.

📒 Files selected for processing (37)
  • backend/apps/cloud/src/analytics/analytics.controller.ts
  • backend/apps/cloud/src/analytics/analytics.service.ts
  • backend/apps/cloud/src/analytics/dto/get-funnel-sessions.dto.ts
  • backend/apps/cloud/src/analytics/dto/getFunnels.dto.ts
  • backend/apps/cloud/src/analytics/interfaces/index.ts
  • backend/apps/cloud/src/goal/dto/goal.dto.ts
  • backend/apps/cloud/src/goal/entity/goal.entity.ts
  • backend/apps/cloud/src/goal/goal.controller.ts
  • backend/apps/community/src/analytics/analytics.controller.ts
  • backend/apps/community/src/analytics/analytics.service.ts
  • backend/apps/community/src/analytics/dto/get-funnel-sessions.dto.ts
  • backend/apps/community/src/analytics/dto/getFunnels.dto.ts
  • backend/apps/community/src/analytics/interfaces/index.ts
  • backend/apps/community/src/goal/dto/goal.dto.ts
  • backend/apps/community/src/goal/entity/goal.entity.ts
  • backend/apps/community/src/goal/goal.controller.ts
  • backend/apps/community/src/goal/goal.service.ts
  • backend/migrations/clickhouse/initialise_selfhosted.js
  • backend/migrations/clickhouse/selfhosted_2025_12_07_goals.js
  • backend/migrations/clickhouse/selfhosted_2026_05_20_goal_conditions.js
  • backend/migrations/mysql/2026_05_20_goal_conditions.sql
  • docs/content/docs/analytics-dashboard/funnels.mdx
  • docs/content/docs/analytics-dashboard/goals.mdx
  • docs/content/docs/api/stats.mdx
  • web/app/api/api.server.ts
  • web/app/lib/models/Project.ts
  • web/app/pages/Project/View/ViewProject.tsx
  • web/app/pages/Project/View/components/FilterRowsEditor.tsx
  • web/app/pages/Project/View/utils/projectViewSegments.ts
  • web/app/pages/Project/tabs/Funnels/FunnelsView.tsx
  • web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx
  • web/app/pages/Project/tabs/Goals/GoalsView.tsx
  • web/app/pages/Project/tabs/Traffic/SessionsDrawer.tsx
  • web/app/routes/api.analytics.ts
  • web/app/routes/projects.$id.tsx
  • web/app/ui/FilterValueInput.tsx
  • web/public/locales/en.json

Comment thread backend/apps/cloud/src/analytics/analytics.service.ts
Comment thread backend/apps/community/src/analytics/analytics.service.ts Outdated
Comment thread web/app/routes/projects.$id.tsx Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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 (3)
backend/apps/community/src/goal/goal.controller.ts (1)

829-837: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Exclude psid = 0 from the total-session count here as well.

The numerator only considers real sessions (psid != 0 inside the conversions subquery), but the denominator currently does not. That makes conversionRate too low whenever unattributed events are present.

🛠️ Minimal fix
     const totalSessionsQuery = `
       SELECT uniqExact(psid) as totalSessions
       FROM events
       WHERE 
         pid = {pid:FixedString(12)}
         AND type IN ('pageview', 'custom_event')
+        AND psid != 0
         ${filtersQuery}
         AND created BETWEEN {groupFrom:String} AND {groupTo:String}
     `
🤖 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 `@backend/apps/community/src/goal/goal.controller.ts` around lines 829 - 837,
The totalSessionsQuery currently counts uniqExact(psid) but doesn't exclude
unattributed sessions (psid = 0), causing the denominator to mismatch the
conversions subquery; update the WHERE clause in the totalSessionsQuery (the
variable named totalSessionsQuery in goal.controller.ts) to add a condition
excluding psid = 0 (e.g., AND psid != 0) so the total session count matches the
conversions logic that already filters psid != 0.
backend/apps/cloud/src/goal/goal.controller.ts (1)

911-919: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep the conversion-rate denominator aligned with the conversions subquery.

The conversions CTE filters out psid = 0, but totalSessionsQuery still counts it. That understates conversionRate whenever unattributed events exist, because the denominator can include one extra pseudo-session that can never convert.

🛠️ Minimal fix
     const totalSessionsQuery = `
       SELECT uniqExact(psid) as totalSessions
       FROM events
       WHERE 
         pid = {pid:FixedString(12)}
         AND type IN ('pageview', 'custom_event')
+        AND psid != 0
         ${filtersQuery}
         AND created BETWEEN {groupFrom:String} AND {groupTo:String}
     `
🤖 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 `@backend/apps/cloud/src/goal/goal.controller.ts` around lines 911 - 919, The
totalSessionsQuery currently counts all psid values but the conversions CTE
excludes psid = 0, causing a mismatched denominator; update the
totalSessionsQuery (the template string named totalSessionsQuery) to apply the
same psid filter as conversions (e.g., add "AND psid != 0" or the same condition
used in the conversions CTE) so the denominator and conversions subquery are
aligned when computing conversionRate.
web/app/pages/Project/View/components/FilterRowsEditor.tsx (1)

355-362: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Don’t disable suggestions for static ev:value / tag:value columns.

The new fast-path treats every column containing : as non-fetchable. That catches ev:value and tag:value too, so those valid columns now get an empty cache and never call fetchFilters().

🛠️ Suggested fix
     async (column: string) => {
-      if (getMetadataColumnBase(column) !== column || column.includes(':')) {
+      if (getMetadataColumnBase(column) !== column) {
         setFilterValuesCache((prev) =>
           prev[column] ? prev : { ...prev, [column]: [] },
         )
         return
       }
🤖 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 `@web/app/pages/Project/View/components/FilterRowsEditor.tsx` around lines 355
- 362, The fast-path in fetchFilterValues currently treats any column with ':'
as non-fetchable and sets an empty cache; update the condition in
fetchFilterValues (and related logic that uses getMetadataColumnBase and
setFilterValuesCache) so that columns with ':' are only skipped when they are
not metadata base OR when they are namespaced columns that we explicitly want to
ignore — allow known static namespaced columns like those starting with "ev:" or
"tag:" to proceed to fetchFilters() instead of short-circuiting; change the if
so it checks getMetadataColumnBase(column) !== column &&
!(column.startsWith('ev:') || column.startsWith('tag:')) (or equivalent
whitelist) so ev:value and tag:value run the normal fetch path and populate the
cache.
♻️ Duplicate comments (1)
backend/apps/community/src/analytics/analytics.service.ts (1)

2152-2174: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

sessionStart and firstPageAt are still anchored to the historical psid, not the matched funnel visit.

session_starts still does min(s.firstSeen) from sessions FINAL, so a reused psid keeps pulling in the oldest visit that ever used that identifier. That makes fromSessionStart and fromFirstPage drift upward again for later conversions, because first_pages scans from that inflated start instead of the visit that actually produced conversionAt. Please derive the visit boundary from the matched funnel run itself, or from a true per-visit session record, before calculating these two metrics.

🤖 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 `@backend/apps/community/src/analytics/analytics.service.ts` around lines 2152
- 2174, session_starts and first_pages currently compute sessionStart as
min(s.firstSeen) over all sessions for a psid, which pulls the oldest visit;
instead, identify the specific session that matches the funnel run and use that
session's boundaries. Change the session_starts CTE to join sessions FINAL s to
funnel_sessions fs with the predicate s.firstSeen <= fs.firstStepAt AND
s.lastSeen >= fs.conversionAt and select s.firstSeen (not min(...)) as
sessionStart (or otherwise use a per-run visit start column from fs if present),
and update first_pages to use that sessionStart (or s.firstSeen constrained to
the matched session) when computing firstPageAt; reference CTEs session_starts,
first_pages and tables/aliases funnel_sessions fs and sessions FINAL s, and
fields sessionStart, firstPageAt, fs.firstStepAt and fs.conversionAt.
🤖 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 `@web/app/pages/Project/View/components/FilterRowsEditor.tsx`:
- Around line 78-97: The operator-restriction logic only matches exact column
names so dynamic metadata keys like "ev:key:utm_campaign" bypass rules; update
getOperatorsForColumn to detect metadata-key columns by prefix (e.g.,
column.startsWith('ev:key') || column.startsWith('tag:key')) instead of strict
equality, so it returns the restricted OPERATORS for any dynamic metadata-key
column, and keep normalizeFilterOperator using getOperatorsForColumn so
unsupported preloaded operators get replaced with the first allowed option.

In `@web/app/routes/projects`.$id.tsx:
- Around line 165-216: isGoalConditions currently only validates shape and
allows cases the backend converts to impossible queries; update isGoalConditions
(used by parseGoalJsonFields) to reject unsupported/ambiguous cases: enforce
that condition.field is one of the allowed field names for the given
condition.eventType (use GOAL_CONDITION_EVENT_TYPES to map/validate allowed
fields), require condition.metadataKey to be a non-empty string whenever
relation/ eventType implies a metadata condition (or when condition.field
indicates metadata access), and for operators in GOAL_CONDITION_OPERATORS that
require a value, reject condition.value if it's undefined or an empty string;
return false for any of these invalid cases so parseGoalJsonFields returns an
error instead of accepting shapes the backend would turn into 1=0.

---

Outside diff comments:
In `@backend/apps/cloud/src/goal/goal.controller.ts`:
- Around line 911-919: The totalSessionsQuery currently counts all psid values
but the conversions CTE excludes psid = 0, causing a mismatched denominator;
update the totalSessionsQuery (the template string named totalSessionsQuery) to
apply the same psid filter as conversions (e.g., add "AND psid != 0" or the same
condition used in the conversions CTE) so the denominator and conversions
subquery are aligned when computing conversionRate.

In `@backend/apps/community/src/goal/goal.controller.ts`:
- Around line 829-837: The totalSessionsQuery currently counts uniqExact(psid)
but doesn't exclude unattributed sessions (psid = 0), causing the denominator to
mismatch the conversions subquery; update the WHERE clause in the
totalSessionsQuery (the variable named totalSessionsQuery in goal.controller.ts)
to add a condition excluding psid = 0 (e.g., AND psid != 0) so the total session
count matches the conversions logic that already filters psid != 0.

In `@web/app/pages/Project/View/components/FilterRowsEditor.tsx`:
- Around line 355-362: The fast-path in fetchFilterValues currently treats any
column with ':' as non-fetchable and sets an empty cache; update the condition
in fetchFilterValues (and related logic that uses getMetadataColumnBase and
setFilterValuesCache) so that columns with ':' are only skipped when they are
not metadata base OR when they are namespaced columns that we explicitly want to
ignore — allow known static namespaced columns like those starting with "ev:" or
"tag:" to proceed to fetchFilters() instead of short-circuiting; change the if
so it checks getMetadataColumnBase(column) !== column &&
!(column.startsWith('ev:') || column.startsWith('tag:')) (or equivalent
whitelist) so ev:value and tag:value run the normal fetch path and populate the
cache.

---

Duplicate comments:
In `@backend/apps/community/src/analytics/analytics.service.ts`:
- Around line 2152-2174: session_starts and first_pages currently compute
sessionStart as min(s.firstSeen) over all sessions for a psid, which pulls the
oldest visit; instead, identify the specific session that matches the funnel run
and use that session's boundaries. Change the session_starts CTE to join
sessions FINAL s to funnel_sessions fs with the predicate s.firstSeen <=
fs.firstStepAt AND s.lastSeen >= fs.conversionAt and select s.firstSeen (not
min(...)) as sessionStart (or otherwise use a per-run visit start column from fs
if present), and update first_pages to use that sessionStart (or s.firstSeen
constrained to the matched session) when computing firstPageAt; reference CTEs
session_starts, first_pages and tables/aliases funnel_sessions fs and sessions
FINAL s, and fields sessionStart, firstPageAt, fs.firstStepAt and
fs.conversionAt.
🪄 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

Run ID: f555ab4f-a48e-4bf3-8af5-68c73b15efc2

📥 Commits

Reviewing files that changed from the base of the PR and between 35a50e9 and d76adcc.

📒 Files selected for processing (6)
  • backend/apps/cloud/src/analytics/analytics.service.ts
  • backend/apps/cloud/src/goal/goal.controller.ts
  • backend/apps/community/src/analytics/analytics.service.ts
  • backend/apps/community/src/goal/goal.controller.ts
  • web/app/pages/Project/View/components/FilterRowsEditor.tsx
  • web/app/routes/projects.$id.tsx

Comment thread web/app/pages/Project/View/components/FilterRowsEditor.tsx
Comment thread web/app/routes/projects.$id.tsx
@Blaumaus Blaumaus merged commit 73b1472 into main May 21, 2026
12 checks passed
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