Skip to content

Advanced Analytics Filters#3436

Merged
pepeladeira merged 32 commits intodubinc:feat/advanced-filtersfrom
pepeladeira:main
Feb 11, 2026
Merged

Advanced Analytics Filters#3436
pepeladeira merged 32 commits intodubinc:feat/advanced-filtersfrom
pepeladeira:main

Conversation

@pepeladeira
Copy link
Collaborator

@pepeladeira pepeladeira commented Feb 7, 2026

Adds support for advanced filter operators in analytics: IS, IS NOT.

Changes

Frontend:

  • Multi-select filter UI with checkboxes
  • Live filtering (applies immediately)
  • Operator toggle between "is" and "is not"
  • Smart display: shows individual values or count ("2 countries")
  • URL format: ?country=US,BR or ?country=-US for negation

Backend:

  • Shared parse function in @dub/utils for frontend/backend consistency
  • Zod schema transforms for filter parameters
  • Generic SQL builder for all filter types
  • Backward compatible with existing single-value filters

Tinybird:

  • Updated 5 pipes (count, timeseries, group_by, group_by_link_metadata, events)
  • Advanced filter SQL for 11 filter types (country, city, device, browser, etc.)
  • JSON-based filter system for scalability

Summary by CodeRabbit

  • New Features

    • Advanced analytics filters: multi-value selection, NOT operators, per-filter operator toggles, and single/multi-select behavior.
    • UI: explicit remove/toggle controls for filters and improved filter selection UX.
  • Improvements

    • Better legacy-query normalization, safer filter parsing, and unified analytics/events filtering behavior.
    • Expanded filter coverage (domain, tags, folder, root, UTMs, metadata, region/country/trigger).
  • Tests

    • New unit and integration suites covering advanced filters and events.
  • Chores

    • Project package manager bumped to 9.15.9.

@CLAassistant
Copy link

CLAassistant commented Feb 7, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 7, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.
📝 Walkthrough

Walkthrough

Replaces simple query parsing with backward-compatible parsers and operator-aware multi-value filters; adds filter helper utilities, threads normalized filters into analytics/event pipelines and Tinybird pipes, updates UI filter types/components and hooks for operator/multi-value flows, converts boolean root filters to structured ParsedFilter objects, and adds tests.

Changes

Cohort / File(s) Summary
Schemas & Parsers
apps/web/lib/zod/schemas/analytics.ts, apps/web/lib/zod/schemas/partners.ts
Add parseAnalyticsQuery/parseEventsQuery; refactor/expand analytics schema to support filter objects, pluralized IDs, backward-compat transforms; make partner schema self-contained.
Filter Utilities
packages/utils/src/functions/parse-filter-value.ts, packages/utils/src/functions/index.ts, apps/web/lib/analytics/filter-helpers.ts
Add parse/build filter utils and comprehensive helpers (getFirstFilterValue, prepareFiltersForPipe, extractWorkspaceLinkFilters, buildAdvancedFilters) to normalize operator/values and prepare Tinybird params.
Analytics Core
apps/web/lib/analytics/get-analytics.ts, apps/web/lib/analytics/get-events.ts
Compose metadata + advanced filters, derive trigger/region/country for pipes, send JSON(filters) to Tinybird, merge filters/metadata, and adjust event/link transformation to new shapes.
API Routes
apps/web/app/.../analytics/*.ts, apps/web/app/(ee)/api/.../events/*.ts, apps/web/app/api/events/route.ts
Switch route parsing to parseAnalyticsQuery/parseEventsQuery, rename parsed fields (domaindomainFilter, folderIdfolderIdFilter), extract first-filter values, and pass structured filters into analytics logic.
Tinybird Pipes
packages/tinybird/pipes/v3_*.pipe
Introduce JSON(filters)-driven filtering, per-field operator handling (IN / NOT IN), metadata comparators, and consistent utm/url matching across pipes.
UI Filter Types & Components
packages/ui/src/filter/types.ts, packages/ui/src/filter/filter-select.tsx, packages/ui/src/filter/filter-list.tsx, packages/ui/src/filter/index.ts
Add FilterOperator/ActiveFilter types and normalizeActiveFilter; expand FilterSelect/FilterList props (isAdvancedFilter, onRemoveFilter, onToggleOperator), support multi-value selection and operator UI, refactor list rendering.
Analytics UI Hooks & Toggle
apps/web/ui/analytics/use-analytics-filters.tsx, apps/web/ui/analytics/toggle.tsx
Parse dynamic filters into operator/values, add onRemoveFilter/onToggleOperator, expose multi-value/operator-aware active filters, and propagate isAdvancedFilter to components.
Link Filters & Page Propagation
apps/web/ui/links/use-link-filters.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/links/page-client.tsx
Expose onRemoveFilter from useLinkFilters, adjust tag filter labeling/hideOperator, and propagate new callback into page client.
Types
apps/web/lib/analytics/types.ts
Rework AnalyticsFilters to `Partial<Omit<analyticsQuerySchema,'start'
Scripts / Cron
apps/web/app/(ee)/api/cron/usage/utils.ts, apps/web/scripts/*.ts
Change root filter usage from boolean to structured ParsedFilter object ({ values:["false"], operator:"IS", sqlOperator:"IN" }).
Tests
apps/web/tests/analytics/*, apps/web/tests/events/index.test.ts
Add unit and integration tests for parse/build filter value, advanced filters, normalization, and Events API behavior covering operator/multi-value scenarios.
Minor / Misc
apps/web/lib/stripe/index.ts, package.json
Minor formatting change and bump root packageManager pnpm version.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant API as "API Route"
    participant Parser as "parseAnalyticsQuery / parseEventsQuery"
    participant Helpers as "filter-helpers"
    participant Service as "get-analytics / get-events"
    participant Tinybird

    Client->>API: GET /analytics or /events?...
    API->>Parser: parseAnalyticsQuery(searchParams)
    Parser-->>API: parsedParams (domainFilter, folderIdFilter, filters...)
    API->>Helpers: getFirstFilterValue / buildAdvancedFilters / extractWorkspaceLinkFilters
    Helpers-->>API: domain, folderId, advancedFilters, tinybirdParams
    API->>Service: call get-analytics/get-events with tinybirdParams (JSON(filters))
    Service->>Tinybird: POST pipe with merged metadata + filters
    Tinybird-->>Service: results
    Service-->>API: formatted response
    API-->>Client: 200 JSON
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • devkiran
  • steven-tey

Poem

"I'm a rabbit in the code so spry,
I hop through filters, IS and NOT I spy.
One or many values in a tasty string,
I tuck them into pipes—Tinybird will sing.
Hooray for filters — carrots for the sky! 🥕"

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Advanced Analytics Filters' directly and clearly summarizes the main feature being introduced: support for advanced filter operators in analytics queries.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

@vercel
Copy link
Contributor

vercel bot commented Feb 7, 2026

@pepeladeira is attempting to deploy a commit to the Dub Team on Vercel.

A member of the Team first needs to authorize it.

Copy link
Contributor

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

Caution

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

⚠️ Outside diff range comments (2)
apps/web/lib/zod/schemas/analytics.ts (1)

76-89: ⚠️ Potential issue | 🟡 Minor

Change deprecated: false to deprecated: true for consistency.

The linkId field is functionally deprecated — the parseAnalyticsQuery function explicitly converts it to linkIds for backward compatibility, and its own documentation (lines 415–416) lists it as a deprecated singular field alongside tagId. However, the metadata marks it as deprecated: false on line 82, which contradicts this design. To match the pattern used for tagId (line 356: .meta({ deprecated: true })) and accurately reflect the field's status in API tooling and documentation, this should be deprecated: true.

packages/tinybird/pipes/v3_events.pipe (1)

491-512: ⚠️ Potential issue | 🔴 Critical

sale_events node is missing field-based advanced filter handling from JSON(filters).

The click_events and lead_events nodes in this file both include the {% elif item.get('field', '') %} branch to handle field-based filters (country, city, device, etc. with IN/NOT IN). The sale_events node only handles the metadata. operand filters (lines 493-509) and has no field-based branch.

This means advanced filters like country NOT IN ['US'] will be silently ignored for sale events on the /api/events endpoint, while working correctly for clicks and leads. Compare with v3_count.pipe count_sales (lines 495-589) which correctly includes this branch.

Additionally, the saleType filter from JSON(filters) won't be applied here — line 515 only handles the legacy scalar saleType param.

🤖 Fix all issues with AI agents
In `@apps/web/app/api/analytics/export/route.ts`:
- Around line 25-31: The duplicated extraction of scalar values from filter
objects (domainFilter and folderIdFilter) should be centralized: update
parseAnalyticsQuery (and parseEventsQuery) to return domain and folderId as
strings alongside the existing filter objects, or create a small shared helper
(e.g., extractFilterValue(filter): string | undefined) and replace the repeated
logic in route handlers; ensure the helper/parse function handles plain-string
inputs (not only objects with a values array) so it returns the string value
instead of falling through to undefined, and update references in
apps/web/app/api/analytics/export/route.ts (domainFilter, folderIdFilter) and
the corresponding events/dashboard/admin routes to use the new returned
domain/folderId symbols.

In `@apps/web/lib/analytics/get-events.ts`:
- Around line 131-133: The code passes regionForPipe (which can be a
ParsedFilter object) directly to the pipe; update the spread that builds the
pipe params in get-events.ts to only include region when regionForPipe is a
string—mirroring the existing guards for triggerForPipe and countryForPipe—so
change the region spread to use a typeof check (e.g., typeof regionForPipe ===
"string" ? { region: regionForPipe } : {}) and ensure this logic corresponds to
the prepareFiltersForPipe output and the pipe’s Zod schema expectations.

In `@apps/web/lib/stripe/index.ts`:
- Around line 4-12: The exported stripe variable is now Stripe | null but call
sites (e.g., createPaymentIntent, webhook handlers, billing routes) call stripe
methods directly; add null checks or enforce runtime assertion. Fix by either
(A) guarding every use: check if stripe is null at the start of functions like
createPaymentIntent and return a 500/error response (or throw) with a clear
message when STRIPE_SECRET_KEY is missing before calling
stripe.paymentMethods.list() or other methods, or (B) replace the nullable
export with a runtime assertion/wrapper that throws on import if
process.env.STRIPE_SECRET_KEY is unset so callers can assume non-null; update
documentation/comments accordingly and apply the chosen pattern consistently
across all files that reference the stripe export.

In `@apps/web/lib/zod/schemas/analytics.ts`:
- Around line 184-229: In the os transform (the zod schema block using
parseFilterValue), extend the special-casing logic in the values mapping to
handle macOS like iOS; i.e., for each val check val.toLowerCase() and return
"iOS" when "ios", return "macOS" when "macos", otherwise fall back to
capitalize(val), keeping the existing .filter(Boolean) and cast—this change
should be made in the values: parsed.values.map(...) inside the os transform.

In `@apps/web/ui/analytics/use-analytics-filters.tsx`:
- Around line 930-944: The code can set the literal string "undefined" into
query params when parseFilterParam returns undefined; update the removal logic
in use-analytics-filters.tsx to first guard parsed and newValues: if parsed is
falsy, return early (or call queryParams({ del: key, scroll: false }) if you
want to remove the param) so you never build newParam from undefined, and
additionally ensure newValues is a defined array before checking length and
constructing newParam (references: parseFilterParam, newValues, newParam,
queryParams).
- Around line 987-992: The onRemoveFilter callback currently calls queryParams({
del: key }) which fails when key is "link" because the URL uses "domain" and
"key" (and sometimes "url"); update onRemoveFilter (used where activeFilter.key
can be "link") to mirror the logic in onRemove: when key === "link" call
queryParams({ del: ["domain","key","url"], scroll: false }), otherwise call
queryParams({ del: key, scroll: false }). Ensure you update the onRemoveFilter
function reference so it deletes the correct param names for the "link"
activeFilter.

In `@packages/tinybird/pipes/v3_count.pipe`:
- Around line 28-51: The template currently applies folder_id twice: the legacy
scalar branch (elif defined(folderId) -> AND folder_id = {{ folderId }}) and the
later operator-aware branch (checks folderIdOperator and emits IN/NOT IN), which
creates contradictory clauses when folderIdOperator == 'NOT IN'; remove the
legacy scalar branch that emits "AND folder_id = {{ folderId }}" so the
operator-aware block (using folderId, folderIdOperator and
Array(folderId,'String')) is the single source of truth. Make this same change
in the other affected templates (v3_events.pipe, v3_group_by.pipe,
v3_group_by_link_metadata.pipe) to avoid duplicate/conflicting folder_id
filters.

In `@packages/tinybird/pipes/v3_timeseries.pipe`:
- Around line 101-124: The template emits conflicting folder filters: when
folderId is set the earlier elif branch creates "AND folder_id = {{ folderId }}"
while later logic also emits folder_id IN/NOT IN based on folderIdOperator,
causing contradictions (e.g., folder_id = X AND folder_id NOT IN (X)). Remove
the redundant scalar equality or guard it so it only runs when folderIdOperator
is not defined—i.e., change the block that emits "AND folder_id = {{ folderId
}}" to execute only if folderIdOperator is not defined (check
String(folderIdOperator) or use {% if not defined(folderIdOperator) %}) and keep
the later folder_id IN/NOT IN logic using folderIdOperator, referencing
folderId, folderIds, folderIdOperator and folder_id.

In `@packages/ui/src/filter/filter-list.tsx`:
- Around line 620-632: The forEach callback is implicitly returning the result
of onRemove which triggers a static-analysis warning; update the onClick handler
(the branch that calls values.forEach) to use an explicit block callback so the
intent is clear: replace values.forEach(value => onRemove(filterKey, value))
with a block form that calls onRemove(filterKey, value) inside braces (e.g.,
values.forEach(value => { onRemove(filterKey, value); })), leaving the
onRemoveFilter branch unchanged.

In `@packages/ui/src/filter/types.ts`:
- Around line 71-98: The normalizeActiveFilter function currently treats objects
with an explicit operator key set to a falsy value (e.g., { key, values,
operator: undefined }) as missing operator and falls through to the empty-values
default; update the first branch in normalizeActiveFilter to detect when the
operator key exists (use "'operator' in filter") and values is an array
(Array.isArray(filter.values)), and in that case return an ActiveFilter using
the provided values and a safe operator (use the existing filter.operator if
truthy, otherwise default to 'IS'); reference normalizeActiveFilter,
ActiveFilterInput, and LegacyActiveFilterPlural when making this change so that
objects with operator: undefined preserve their values and get a sensible
operator fallback.
🧹 Nitpick comments (26)
apps/web/scripts/dub-wrapped.ts (1)

64-64: Consider using parseFilterValue("false") instead of a hardcoded filter object.

This literal object is repeated across multiple scripts and the cron utility. Since parseFilterValue from @dub/utils produces the exact same structure, using it would be DRYer and less prone to drift if the filter shape evolves.

-          root: { values: ["false"], operator: "IS", sqlOperator: "IN" },
+          root: parseFilterValue("false")!,
apps/web/scripts/get-top-links-for-workspace.ts (1)

42-42: Same hardcoded filter object — same refactor opportunity as in dub-wrapped.ts.

apps/web/scripts/download-top-links.ts (1)

14-14: Same hardcoded filter object — same refactor opportunity as in dub-wrapped.ts.

apps/web/app/(ee)/api/cron/usage/utils.ts (1)

125-125: Same hardcoded filter object — same refactor opportunity as in dub-wrapped.ts.

packages/utils/src/functions/parse-filter-value.ts (1)

63-65: buildFilterValue with empty values array would produce "-" or "" — consider a guard.

If a manually constructed ParsedFilter with an empty values array is passed, buildFilterValue returns "-" (for NOT operators) or "" (for IS operators), both of which would round-trip incorrectly through parseFilterValue. Since parseFilterValue never produces empty arrays, this is low-risk but could bite future callers.

apps/web/lib/analytics/types.ts (1)

16-16: Remove the unused Override type utility.

The Override type at line 16 is not used anywhere in the codebase. AnalyticsFilters no longer references it and instead uses inline type composition. This is dead code that should be deleted.

apps/web/tests/analytics/normalize-filter.test.ts (1)

132-143: Consider adding a test for operator: undefined edge case.

The edge cases section tests { key: "country" } (no operator, no values) but doesn't cover { key: "country", values: ["US"], operator: undefined }, which would currently drop the values silently (falls through to the default branch).

Suggested test
+    test("handles filter with explicit undefined operator", () => {
+      const input = {
+        key: "country",
+        values: ["US", "BR"],
+        operator: undefined,
+      } as any;
+      const result = normalizeActiveFilter(input);
+      expect(result).toEqual({
+        key: "country",
+        operator: "IS_ONE_OF",
+        values: ["US", "BR"],
+      });
+    });
packages/tinybird/pipes/v3_timeseries.pipe (2)

177-276: Massive filter-block duplication across nodes — consider extracting a shared include.

The same ~100-line filter template is copy-pasted verbatim in timeseries_clicks_data, timeseries_leads_data, and timeseries_sales_data (with minor se. prefix differences in sales). This makes maintenance error-prone — any field added or bug fixed must be replicated in all three (or more, given 5 pipes are modified per the PR summary).

Tinybird supports INCLUDE directives for shared SQL fragments. Consider extracting the filter block into a shared include to keep the pipes DRY.


297-455: Leads node still has legacy single-value filters alongside new JSON filters — potential double-filtering.

timeseries_leads_data retains legacy scalar filters (e.g., AND country = {{ country }} at Line 328) while also applying the new JSON filters block (Lines 354-453). If both the legacy country parameter and a country entry in the filters JSON are provided simultaneously, both WHERE conditions apply, which is likely redundant but could cause confusion.

The clicks node was already migrated to JSON-only filtering. Consider whether the leads and sales nodes should follow suit to avoid this ambiguity, or document clearly that legacy params and JSON filters should not overlap.

apps/web/lib/zod/schemas/analytics.ts (2)

417-432: DRY: parseAnalyticsQuery and parseEventsQuery share identical backward-compat logic.

Both functions perform the same linkId → linkIds and tagId → tagIds conversions. Consider extracting the shared migration into a helper to avoid drift.

Suggested refactor
+function applyBackwardCompat(data: Record<string, any>) {
+  if (data.linkId && !data.linkIds) {
+    data.linkIds = [data.linkId];
+  }
+  if (data.tagId && !data.tagIds) {
+    data.tagIds = { operator: "IS" as const, sqlOperator: "IN" as const, values: [data.tagId] };
+  }
+  return data;
+}
+
 export function parseAnalyticsQuery(searchParams: unknown) {
   const data = analyticsQuerySchema.parse(searchParams);
-  if (data.linkId && !data.linkIds) {
-    data.linkIds = [data.linkId];
-  }
-  if (data.tagId && !data.tagIds) {
-    data.tagIds = { operator: "IS" as const, sqlOperator: "IN" as const, values: [data.tagId] };
-  }
-  return data;
+  return applyBackwardCompat(data);
 }

 export function parseEventsQuery(searchParams: unknown) {
   const data = eventsQuerySchema.parse(searchParams);
-  if (data.linkId && !data.linkIds) {
-    data.linkIds = [data.linkId];
-  }
-  if (data.tagId && !data.tagIds) {
-    data.tagIds = { operator: "IS" as const, sqlOperator: "IN" as const, values: [data.tagId] };
-  }
-  return data;
+  return applyBackwardCompat(data);
 }

Also applies to: 543-557


434-495: folderId and folderIds serve different purposes but have confusing names.

folderId (line 456) is meant for operator-based filtering with a corresponding folderIdOperator field (line 473), while folderIds (line 451) is a simpler multi-value field. Both accept string | string[] and transform identically, making the distinction unclear. Rename one or add explicit inline documentation explaining when each should be used—folderId for advanced filtering with IN/NOT IN operators, folderIds for simple multi-value queries.

apps/web/app/api/analytics/route.ts (1)

51-58: Repeated domain/folderId extraction pattern — consider a shared helper.

This object-check-and-extract pattern is duplicated in dashboard/route.ts (and likely export/route.ts). A small utility would reduce boilerplate and prevent drift:

function extractFirstValue(filter: ParsedFilter | undefined): string | undefined {
  return filter?.values[0];
}

Also, when a multi-value domain filter is provided (e.g., domain=dub.co,google.com), only values[0] is validated via getDomainOrThrow on line 84. The remaining domains skip the ownership check. This isn't a data-leak risk since analytics are workspace-scoped, but it means invalid domains silently pass validation.

apps/web/app/api/analytics/dashboard/route.ts (1)

147-160: parsedParams now contains ParsedFilter objects — verify cache key stability and log readability.

JSON.stringify(parsedParams) will serialize ParsedFilter objects (with operator, sqlOperator, values fields) deterministically for identical inputs, so cache correctness is maintained. However, the cache key is now longer/more complex, and the console logs on lines 152-159 will output the full nested structure. Just flagging for awareness.

apps/web/tests/analytics/index.test.ts (3)

62-79: Backward compatibility test is identical to the single country filter test.

The "backward compatibility" test (lines 212–229) sends country: "US" with groupBy: "count" and asserts status 200 + clicks property — exactly the same as the "single country filter" test (lines 63–79). It doesn't exercise any distinct legacy format. Consider testing the old single-value format in a way that actually differs (e.g., different parameter shape, deprecated query key, or at least a differentiating comment explaining what "old format" means here).

Also applies to: 212-229


193-210: Timeseries test lacks meaningful assertions beyond status code.

The comment says "Timeseries should only include US data," but there's no actual assertion validating that. If data comes back containing non-US entries, this test would still pass. Consider validating the timeseries array elements or at least the schema shape (e.g., each entry has a start timestamp and clicks field).


95-101: Empty result sets cause all forEach assertions to pass vacuously.

If the test link has no click data matching these filters, every data.forEach(...) block passes without checking anything. Consider adding expect(data.length).toBeGreaterThan(0) guards (or at minimum acknowledging this in the test) for the tests that rely on inspecting individual items.

Also applies to: 118-124, 140-147, 164-170

apps/web/tests/analytics/advanced-filters.test.ts (1)

274-290: Test name "handles all supported fields" only covers 5 of the supported fields.

The PR mentions 11 filter types (country, city, device, browser, os, region, referer, referer_url, continent, trigger, url). Consider either renaming this test to "handles multiple supported fields" or expanding it to cover all entries in SUPPORTED_FIELDS to match the test name.

apps/web/ui/analytics/toggle.tsx (1)

86-86: isAdvancedFilter is unconditionally true — consider making it configurable.

Both Filter.Select and Filter.List receive isAdvancedFilter={true} with no way to disable advanced filtering. If this is the intended final state for all analytics views, this is fine. However, if other consumers of AnalyticsToggle (e.g., partner pages, dashboards) might not want advanced filter UI, consider making it a prop.

Also applies to: 297-297

apps/web/tests/events/index.test.ts (1)

70-188: Advanced filter tests for events are weaker than their analytics counterparts.

All advanced filter tests only assert status === 200 and Array.isArray(data). Unlike the analytics index.test.ts which validates returned country/device values, these tests don't verify the filters actually affected the response. At minimum, consider schema-validating the response with clickEventResponseSchema and, where possible, asserting on the filtered field values.

apps/web/lib/analytics/get-analytics.ts (2)

144-171: tinybirdParams typed as any defeats type safety in a critical path.

This object assembles all parameters sent to the Tinybird analytics pipe. Typing it as any means typos in property names, wrong value types, or missing required fields won't be caught at compile time. Consider using z.input<typeof analyticsFilterTB> or a dedicated interface to get type checking here.

Suggested approach
-  const tinybirdParams: any = {
+  const tinybirdParams: z.input<typeof analyticsFilterTB> = {

If analyticsFilterTB doesn't perfectly match the runtime shape, a partial/intersection type could bridge the gap while still catching obvious mistakes.


98-109: Multiple non-null assertions on groupBy throughout the function.

groupBy! is used at lines 99, 107, 343, and 348, and event! at line 179. If getAnalytics can legitimately be called without groupBy or event, these will throw at runtime. Consider adding an early guard or narrowing the type at the top of the function.

Suggested guard
 export const getAnalytics = async (params: AnalyticsFilters) => {
   let {
     event,
     groupBy,
     ...
   } = params;
+
+  if (!groupBy) throw new Error("groupBy is required");
+  if (!event) throw new Error("event is required");
packages/tinybird/pipes/v3_count.pipe (1)

119-218: ~100-line filter block is copy-pasted verbatim across nodes and files.

The JSON(filters) template block (lines 119-218) is duplicated identically in count_clicks, count_leads, and count_sales within this file, and again across v3_events.pipe, v3_group_by.pipe, and v3_group_by_link_metadata.pipe. This amounts to roughly 10+ copies of the same ~100-line block.

While Tinybird's templating language may not support shared includes, consider whether Tinybird's shared data sources/pipes or a code-generation step could reduce this surface. Each future filter type addition requires updating all copies in lockstep, which is error-prone.

apps/web/lib/analytics/get-events.ts (1)

120-146: Consider adding a proper type for tinybirdParams instead of any.

Using any disables all type checking for the pipe parameters. If a field name is misspelled or a value has the wrong shape (e.g., passing a ParsedFilter object where a string is expected), it will silently pass compilation.

apps/web/ui/analytics/use-analytics-filters.tsx (3)

121-123: Unnecessary useCallback wrapper around a pure imported function.

parseFilterValue is a stable module-level import — wrapping it in useCallback with an empty dependency array adds indirection without any memoization benefit. Consider using parseFilterValue directly at call sites.


156-172: Long ternary chain on Line 165 is hard to read and maintain.

The chained ternaries to recover destructured variables are error-prone. A lookup map would be clearer and easier to extend.

♻️ Proposed refactor
+    const destructuredParams: Record<string, string | undefined> = {
+      domain,
+      tagIds,
+      root,
+      folderId,
+    };
+
     VALID_ANALYTICS_FILTERS.forEach((filter) => {
       if (["key", "tagId", "customerId"].includes(filter)) return;
       if (["interval", "start", "end", "qr"].includes(filter)) return;
       if (filter === "domain" && domain && key) return;

-      const value = params[filter] || (filter === "domain" ? domain : filter === "tagIds" ? tagIds : filter === "root" ? root : filter === "folderId" ? folderId : undefined);
+      const value = params[filter] || destructuredParams[filter];
       if (value) {

175-181: partnerPage in the dependency array appears unused inside this useMemo.

It doesn't seem to be referenced in the activeFilters computation (lines 125–174). This won't cause bugs but triggers unnecessary recalculations if the prop changes.

Copy link
Contributor

@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.

Caution

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

⚠️ Outside diff range comments (1)
packages/tinybird/pipes/v3_events.pipe (1)

488-515: ⚠️ Potential issue | 🔴 Critical

Critical: sale_events node is missing field-based JSON filter handling — advanced filters silently ignored for sales.

The sale_events subquery (lines 488–509) only processes metadata operand filters. It does not include the {% elif item.get('field', '') %} branch that click_events (line 139) and lead_events (line 303) have. This means field-based advanced filters (country IN/NOT IN, city, device, browser, utm_*, domain, tagIds, folderId, root, etc.) are silently dropped for sale event queries.

Additionally, line 512 only handles the legacy scalar saleType param ({% if defined(saleType) %}). There is no JSON filters-based saleType handling in the outer WHERE, unlike v3_count.pipe (lines 593–603) and v3_group_by.pipe (lines 755–765).

This will return unfiltered sale events when advanced filters are active.

🐛 Proposed fix: Add field-based filter branch + saleType handling

Add the field-based filter branch inside the sale_events subquery (after the metadata {% end %} at line 507), and add saleType JSON filter handling in the outer WHERE at line 512. The field-based block should mirror what's in click_events (lines 139–226), adapted with se. column prefixes. The outer WHERE should add:

     ) AS t
-    WHERE true {% if defined(saleType) %} AND t.sale_type = {{ String(saleType) }} {% end %}
+    WHERE true
+        {% if defined(saleType) %} AND t.sale_type = {{ String(saleType) }} {% end %}
+        {% if defined(filters) %}
+            {% for item in JSON(filters, '[]') %}
+                {% if item.get('field', '') == 'saleType' %}
+                    {% set operator = item.get('operator', 'IN') %}
+                    {% set values = item.get('values', []) %}
+                    {% if operator == 'IN' %} AND t.sale_type IN {{ Array(values, 'String') }}
+                    {% elif operator == 'NOT IN' %} AND t.sale_type NOT IN {{ Array(values, 'String') }}
+                    {% end %}
+                {% end %}
+            {% end %}
+        {% end %}

And the full field-based filter block (country, city, continent, device, browser, os, trigger, referer, refererUrl, url, utm_*, domain, tagIds, folderId, root, saleType) needs to be added inside the subquery's {% for item in JSON(filters, '[]') %} loop, after the metadata handling.

🧹 Nitpick comments (2)
packages/tinybird/pipes/v3_events.pipe (1)

463-483: Inconsistent UTM LIKE pattern in sale_events vs. other nodes in this file.

The legacy UTM patterns in sale_events (lines 464–483) use a different format—e.g., concat('%utm_source=', ...) without the separated leading '%', 'utm_source=...' pattern used in click_events (line 103) and lead_events (line 267). These lines weren't modified in this PR, but since the other nodes were updated to use concat('%', 'utm_source=', ...), consider aligning sale_events for consistency.

packages/tinybird/pipes/v3_group_by.pipe (1)

161-277: Extract the duplicated ~110-line filter block into a reusable .incl file using Tinybird's INCLUDE directive.

The identical JSON filter template (metadata + 15-field dispatch with IN/NOT IN logic) is copy-pasted 14 times across 5 pipe files: 3 occurrences each in v3_group_by.pipe (nodes group_by_clicks, group_by_leads, group_by_sales), and 11 occurrences spread across v3_count.pipe, v3_timeseries.pipe, v3_events.pipe, and v3_group_by_link_metadata.pipe. Tinybird's templating system supports .incl includes and INCLUDE <path>.incl <variables> directives, making reuse of this block straightforward. With minor variations in table prefixes (se. in v3_count) and field filtering logic (commented out in v3_events and v3_group_by_link_metadata), a single parameterized include would reduce maintenance burden and eliminate divergence risk across these blocks.

Copy link
Contributor

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

🤖 Fix all issues with AI agents
In `@apps/web/ui/analytics/use-analytics-filters.tsx`:
- Around line 974-990: onToggleOperator currently updates the operator param but
doesn’t clear pagination, which can leave users on a stale page; modify the
queryParams call inside onToggleOperator (the function referencing
searchParamsObj and queryParams) to remove the page param like onSelect/onRemove
do (include del: "page" alongside set: { [key]: newParam } and keep scroll:
false) so toggling operators resets pagination.

In `@packages/tinybird/pipes/v3_events.pipe`:
- Around line 585-588: The inner subquery currently filters on se.sale_type (the
raw dub_sale_events_mv column) for the saleType branch, but the meaningful
computed sale_type comes from the first_sale_by_link_customer join and is
already filtered by the outer WHERE on t.sale_type; remove the inner
se.sale_type filter (in the saleType branch) or replace it with a harmless no-op
comment like the other no-op cases (domain/tagIds/root) so the inner query does
not reference the wrong column; locate the saleType branch in the template (the
block checking {% elif field == 'saleType' %}) and make this change so only
t.sale_type is used for saleType filtering.
- Around line 130-137: The template uses JSONExtractString for numeric
comparisons in the operator branches ('greaterThan', 'lessThan',
'greaterThanOrEqual', 'lessThanOrEqual') which yields string comparisons; change
these to extract numeric types (e.g., JSONExtractFloat64 or JSONExtractInt) or
cast the extracted value to a numeric type so comparisons are numeric, and
propagate the same change to the equivalent templates in v3_group_by.pipe and
v3_count.pipe to avoid lexicographic comparison bugs.
- Around line 122-138: The template interpolates user-controlled metadataKey and
value via {% set %} and {{ metadataKey }} / {{ value }}, allowing possible SQL
injection; update the code that builds filters (the item.get('operand'),
metadataKey, and value handling) to validate and sanitize inputs: enforce a
whitelist of allowed metadata keys (check the extracted metadataKey against it),
cast/validate value types (numbers/dates/strings) and escape or pass value as a
Tinybird parameter rather than direct interpolation, and remove/normalize any
characters that could break SQL syntax (quotes, parentheses, comments). Also
adjust the template generation logic that emits JSONExtractString(metadata, {{
metadataKey }}) to use only validated keys (or map keys to safe identifiers) and
ensure comparisons use parameter placeholders for value instead of injecting raw
{{ value }}.

In `@packages/ui/src/filter/filter-list.tsx`:
- Around line 41-60: pluralize currently lowercases input and builds plurals,
causing "OS" -> "oss"; update the irregularPlurals map in the pluralize function
to include 'os': 'OS' (i.e., add the key 'os' with value 'OS') so
pluralize("OS", n>1) returns "OS" rather than "oss"; keep using lowerWord as the
lookup key and return the mapped value (preserving the capitalized
abbreviation).
- Around line 545-555: The Command.Item currently has an empty onSelect and
performs selection only in the onPointerUp handler, which prevents keyboard
(Enter) selection; update Command.Item so that onSelect invokes
toggleValue(option.value) (or calls a helper that does) so selection works for
both keyboard and pointer, remove the empty onSelect stub, and keep or simplify
pointer handlers (e.g., still preventDefault in onPointerDown but do not perform
toggle logic there); reference Command.Item's onSelect, onPointerUp, toggleValue
and option.value to locate and move the toggle logic into onSelect.
🧹 Nitpick comments (8)
apps/web/lib/analytics/get-events.ts (3)

120-146: Consider replacing any with a proper type for tinybirdParams.

Using any here bypasses TypeScript's ability to catch mismatches between what's built and what the pipe expects. A Record<string, unknown> or a dedicated interface would catch typos and mismatches at compile time.


173-176: Defensive testVariants normalization is fine, but mutates the returned object.

transformLink returns a fresh object, so mutating transformedLink is safe here. If transformLink ever caches or reuses objects, this could be a subtle bug. The check !Array.isArray(...) covers cases where testVariants is a non-array truthy value (e.g., an object) — consider whether null coercion is the right default vs. filtering it out entirely, depending on the response schema expectation.


147-148: Trailing whitespace on line 147.

Line 147 has trailing whitespace after the closing brace/semicolon on line 146. Very minor nit.

apps/web/ui/analytics/use-analytics-filters.tsx (2)

121-123: Unnecessary useCallback wrapper.

parseFilterParam is a pure one-liner with an empty dependency array. This stable identity doesn't require useCallback—you can just call parseFilterValue directly at each call site, or declare a plain const outside the component.


165-165: Long chained ternary is hard to follow.

This expression chains 5 ternaries to resolve the value for a filter key. Consider extracting it into a small helper for readability.

Suggested refactor
-      const value = params[filter] || (filter === "domain" ? domain : filter === "tagIds" ? tagIds : filter === "root" ? root : filter === "folderId" ? folderId : undefined);
+      const specialParams: Record<string, string | undefined> = { domain, tagIds, root, folderId };
+      const value = params[filter] || specialParams[filter];
packages/ui/src/filter/filter-list.tsx (1)

110-125: Option lookup is duplicated between displayLabel and OptionDisplay.

Lines 113–124 (for displayLabel) and Lines 136–142 (inside OptionDisplay) perform the same option-finding logic with identical case-insensitive string matching. Consider extracting a shared helper like findOption(filter, value) to reduce duplication.

packages/ui/src/filter/types.ts (1)

9-9: Consider collapsing to just IS and IS_NOT operators.

FilterOperator defines four variants (IS, IS_NOT, IS_ONE_OF, IS_NOT_ONE_OF), but all the actual behavior is determined by value count, not the operator type:

  • UI renders them identically: IS/IS_ONE_OF both display as "is", IS_NOT/IS_NOT_ONE_OF both display as "is not"
  • SQL layer maps them identically: both IS variants become IN, both IS_NOT variants become NOT IN
  • URL encoding only checks negation (via - prefix), not the operator variant itself
  • parseFilterValue() and normalizeActiveFilter() both assign the variant based solely on value.length > 1

Since no UI, SQL, or URL logic actually differentiates between the paired variants, maintaining four operators adds complexity without functional benefit. Consider reducing to just IS and IS_NOT, with the value array's length implying multiplicity.

packages/tinybird/pipes/v3_events.pipe (1)

120-226: ~270 lines of filter template logic duplicated across three nodes.

The JSON field-based filter block is nearly identical across click_events, lead_events, and sale_events, differing only in table alias prefixes and a couple of field inclusions. Tinybird supports INCLUDE directives for shared template fragments — extracting a reusable macro would significantly reduce maintenance burden and divergence risk (e.g., the trigger field is in click_events but missing from the other two nodes, and saleType is handled inconsistently).

Copy link
Contributor

@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.

Caution

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

⚠️ Outside diff range comments (1)
packages/tinybird/pipes/v3_events.pipe (1)

488-509: ⚠️ Potential issue | 🔴 Critical

sale_events is missing the entire field-based JSON filter branch.

Both click_events (lines 139–223) and lead_events (lines 303–383) handle {% elif item.get('field', '') %} inside the {% for item in JSON(filters, '[]') %} loop, applying IN/NOT IN filters for country, city, device, browser, os, UTMs, etc. The sale_events node only processes metadata.* operands and silently drops all field-based filters.

This means advanced filters like "country IS NOT US" will have no effect on sale events — the conditions are never emitted into the SQL.

Additionally, saleType field-based filters need special handling: sale_type is a computed column from the first_sale_by_link_customer join, so it must be filtered in the outer WHERE (line 512), not inside the subquery.

Proposed fix — add field-based branch inside the subquery and saleType in the outer query

Add the field-based filter block after line 506 (inside the subquery, after the metadata block's {% end %}), mirroring the pattern from click_events/lead_events but with se. column prefixes, and excluding saleType (handled separately):

                         {% elif operator == 'lessThanOrEqual' %}
                             AND JSONExtractString(se.metadata, {{ metadataKey }}) <= {{ value }}
                         {% end %}
+                    {% elif item.get('field', '') %}
+                        {% set field = item.get('field', '') %}
+                        {% set operator = item.get('operator', 'IN') %}
+                        {% set values = item.get('values', []) %}
+                        {% if field == 'country' %}
+                            {% if operator == 'IN' %} AND se.country IN {{ Array(values, 'String') }}
+                            {% elif operator == 'NOT IN' %} AND se.country NOT IN {{ Array(values, 'String') }}
+                            {% end %}
+                        {% elif field == 'city' %}
+                            {% if operator == 'IN' %} AND se.city IN {{ Array(values, 'String') }}
+                            {% elif operator == 'NOT IN' %} AND se.city NOT IN {{ Array(values, 'String') }}
+                            {% end %}
+                        {% elif field == 'continent' %}
+                            {% if operator == 'IN' %} AND se.continent IN {{ Array(values, 'String') }}
+                            {% elif operator == 'NOT IN' %} AND se.continent NOT IN {{ Array(values, 'String') }}
+                            {% end %}
+                        {% elif field == 'device' %}
+                            {% if operator == 'IN' %} AND se.device IN {{ Array(values, 'String') }}
+                            {% elif operator == 'NOT IN' %} AND se.device NOT IN {{ Array(values, 'String') }}
+                            {% end %}
+                        {% elif field == 'browser' %}
+                            {% if operator == 'IN' %} AND se.browser IN {{ Array(values, 'String') }}
+                            {% elif operator == 'NOT IN' %} AND se.browser NOT IN {{ Array(values, 'String') }}
+                            {% end %}
+                        {% elif field == 'os' %}
+                            {% if operator == 'IN' %} AND se.os IN {{ Array(values, 'String') }}
+                            {% elif operator == 'NOT IN' %} AND se.os NOT IN {{ Array(values, 'String') }}
+                            {% end %}
+                        {% elif field == 'referer' %}
+                            {% if operator == 'IN' %} AND se.referer IN {{ Array(values, 'String') }}
+                            {% elif operator == 'NOT IN' %} AND se.referer NOT IN {{ Array(values, 'String') }}
+                            {% end %}
+                        {% elif field == 'refererUrl' %}
+                            {% if operator == 'IN' %} AND splitByString('?', se.referer_url)[1] IN {{ Array(values, 'String') }}
+                            {% elif operator == 'NOT IN' %} AND splitByString('?', se.referer_url)[1] NOT IN {{ Array(values, 'String') }}
+                            {% end %}
+                        {% elif field == 'url' %}
+                            {% if operator == 'IN' %} AND splitByString('?', se.url)[1] IN {{ Array(values, 'String') }}
+                            {% elif operator == 'NOT IN' %} AND splitByString('?', se.url)[1] NOT IN {{ Array(values, 'String') }}
+                            {% end %}
+                        {% elif field == 'utm_source' %}
+                            {% if operator == 'IN' %}
+                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})
+                            {% elif operator == 'NOT IN' %}
+                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})
+                            {% end %}
+                        {% elif field == 'utm_medium' %}
+                            {% if operator == 'IN' %}
+                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})
+                            {% elif operator == 'NOT IN' %}
+                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})
+                            {% end %}
+                        {% elif field == 'utm_campaign' %}
+                            {% if operator == 'IN' %}
+                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})
+                            {% elif operator == 'NOT IN' %}
+                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})
+                            {% end %}
+                        {% elif field == 'utm_term' %}
+                            {% if operator == 'IN' %}
+                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})
+                            {% elif operator == 'NOT IN' %}
+                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})
+                            {% end %}
+                        {% elif field == 'utm_content' %}
+                            {% if operator == 'IN' %}
+                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})
+                            {% elif operator == 'NOT IN' %}
+                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})
+                            {% end %}
+                        {% elif field == 'domain' %}
+                            {# domain is already filtered at workspace_links node level #}
+                        {% elif field == 'tagIds' %}
+                            {# tagIds is already filtered at workspace_links node level #}
+                        {% elif field == 'folderId' %}
+                            {# folderId is already filtered at workspace_links node level #}
+                        {% elif field == 'root' %}
+                            {# root is already filtered at workspace_links node level #}
+                        {% elif field == 'saleType' %}
+                            {# saleType is filtered in the outer query where the computed sale_type is available #}
                         {% end %}
                     {% end %}
                 {% end %}
             {% end %}
     ) AS t
-    WHERE true {% if defined(saleType) %} AND t.sale_type = {{ String(saleType) }} {% end %}
+    WHERE true
+        {% if defined(saleType) %} AND t.sale_type = {{ String(saleType) }} {% end %}
+        {% if defined(filters) %}
+            {% for item in JSON(filters, '[]') %}
+                {% if item.get('field', '') == 'saleType' %}
+                    {% set operator = item.get('operator', 'IN') %}
+                    {% set values = item.get('values', []) %}
+                    {% if operator == 'IN' %} AND t.sale_type IN {{ Array(values, 'String') }}
+                    {% elif operator == 'NOT IN' %} AND t.sale_type NOT IN {{ Array(values, 'String') }}
+                    {% end %}
+                {% end %}
+            {% end %}
+        {% end %}
#!/bin/bash
# Verify that click_events and lead_events have the field-based branch but sale_events does not
echo "=== click_events field-based branch ==="
rg -n "item.get\('field'" packages/tinybird/pipes/v3_events.pipe

echo ""
echo "=== sale_events filters section (lines 488-509) ==="
sed -n '488,515p' packages/tinybird/pipes/v3_events.pipe
🧹 Nitpick comments (1)
packages/tinybird/pipes/v3_events.pipe (1)

120-226: ~170 lines of filter logic are duplicated across event nodes.

The field-based and metadata-based JSON filter blocks in click_events and lead_events are nearly identical, and the same block needs to be added to sale_events. This triples the maintenance surface for any filter change.

If Tinybird supports shared include files or macros, consider extracting the filter template into a reusable fragment. If not, a code-generation approach (e.g., a script that produces the pipe file from a single template) would help prevent the kind of drift that caused the sale_events omission above.

#!/bin/bash
# Check if Tinybird supports include files or shared templates
rg -rn "INCLUDE\|include\|import\|macro" packages/tinybird/ --type-add 'pipe:*.pipe' --type pipe | head -20

Also applies to: 284-386

Copy link
Contributor

@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 (2)
packages/tinybird/pipes/v3_group_by.pipe (1)

645-652: ⚠️ Potential issue | 🔴 Critical

Metadata numeric comparisons in group_by_sales are missing toFloat64OrNull — lexicographic instead of numeric.

The group_by_clicks (Line 172) and group_by_leads (Line 391) nodes correctly wrap metadata comparisons with toFloat64OrNull(JSONExtractString(...)), but group_by_sales still uses bare JSONExtractString(...) for greaterThan, lessThan, greaterThanOrEqual, and lessThanOrEqual. This causes string-based comparison where '10' < '9' is true, producing incorrect results.

🐛 Proposed fix
                         {% elif operator == 'greaterThan' %}
-                            AND JSONExtractString(se.metadata, {{ metadataKey }}) > {{ value }}
+                            AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) > toFloat64OrNull({{ value }})
                         {% elif operator == 'lessThan' %}
-                            AND JSONExtractString(se.metadata, {{ metadataKey }}) < {{ value }}
+                            AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) < toFloat64OrNull({{ value }})
                         {% elif operator == 'greaterThanOrEqual' %}
-                            AND JSONExtractString(se.metadata, {{ metadataKey }}) >= {{ value }}
+                            AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) >= toFloat64OrNull({{ value }})
                         {% elif operator == 'lessThanOrEqual' %}
-                            AND JSONExtractString(se.metadata, {{ metadataKey }}) <= {{ value }}
+                            AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) <= toFloat64OrNull({{ value }})
packages/tinybird/pipes/v3_count.pipe (1)

483-490: ⚠️ Potential issue | 🔴 Critical

Same missing toFloat64OrNull issue in count_sales metadata comparisons.

Same bug as group_by_sales in v3_group_by.pipe: the numeric operators (greaterThan, lessThan, etc.) use bare JSONExtractString comparisons, producing lexicographic results instead of numeric. The count_leads node (Line 277) in this same file does it correctly.

🐛 Proposed fix
                             {% elif operator == 'greaterThan' %}
-                                AND JSONExtractString(se.metadata, {{ metadataKey }}) > {{ value }}
+                                AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) > toFloat64OrNull({{ value }})
                             {% elif operator == 'lessThan' %}
-                                AND JSONExtractString(se.metadata, {{ metadataKey }}) < {{ value }}
+                                AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) < toFloat64OrNull({{ value }})
                             {% elif operator == 'greaterThanOrEqual' %}
-                                AND JSONExtractString(se.metadata, {{ metadataKey }}) >= {{ value }}
+                                AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) >= toFloat64OrNull({{ value }})
                             {% elif operator == 'lessThanOrEqual' %}
-                                AND JSONExtractString(se.metadata, {{ metadataKey }}) <= {{ value }}
+                                AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) <= toFloat64OrNull({{ value }})
🤖 Fix all issues with AI agents
In `@apps/web/ui/analytics/use-analytics-filters.tsx`:
- Around line 156-172: The root filter options currently use boolean values
while parseFilterValue/parseFilterParam returns strings, causing mismatched
comparisons; update the root filter options to use string values
("true"/"false") instead of booleans (or alternatively coerce parsed values to
booleans) so values.includes(option.value) can match. Locate the root filter
options (the array that defines option objects for the "root" filter) and change
option.value from true/false to "true"/"false" (or add a normalize step after
parseFilterParam to convert parsed.values items to booleans when filter ===
"root"); ensure parseFilterParam/parseFilterValue behavior and the filters push
(inside the loop over VALID_ANALYTICS_FILTERS using parseFilterParam and
filters.push) remain consistent with the chosen string/boolean normalization.

In `@packages/tinybird/pipes/v3_events.pipe`:
- Around line 213-222: v3_events.pipe currently skips JSON filters for
domain/tagIds/folderId/root (relying on extractWorkspaceLinkFilters() and
separate params), which is inconsistent with v3_count.pipe and v3_group_by.pipe;
update v3_events.pipe to detect these fields in JSON filters and emit the same
inline subqueries (link_id IN (SELECT link_id FROM workspace_links WHERE ...))
as v3_count/v3_group_by so behavior matches, referencing the same filter
structure used in those pipes and ensuring compatibility with
buildAdvancedFilters() and SUPPORTED_FIELDS (keep domainParam/tagIdsParam
handling intact but add JSON-path handling to avoid silent drops if backend
changes).
🧹 Nitpick comments (4)
packages/ui/src/filter/filter-list.tsx (2)

360-386: displayValues prop is declared but unused inside OperatorFilterPill.

The displayValues prop (line 379) is accepted but never read within the component body — it's only used via the OptionDisplay closure defined in the parent. You can remove it from the props interface.

Proposed fix
 function OperatorFilterPill({
   filterKey,
   filter,
   values,
   operator,
   displayLabel,
-  displayValues,
   OptionDisplay,
   onRemove,
   ...

And at the call site (line 235):

               <OperatorFilterPill
                 key={key}
                 filterKey={key}
                 filter={filter}
                 values={values}
                 operator={operator}
                 displayLabel={displayLabel}
-                displayValues={displayValues}
                 OptionDisplay={OptionDisplay}

264-358: Remove unused FilterCombobox function (lines 264–358).

This function is neither called anywhere in the codebase nor exported. It appears to have been superseded by the Command-based dropdown implementation inside OperatorFilterPill. Removing it will reduce maintenance surface.

apps/web/ui/analytics/use-analytics-filters.tsx (2)

121-123: Unnecessary useCallback wrapper around a pure function.

parseFilterValue is a stable import with no dependencies — wrapping it in useCallback([], []) adds indirection without benefit. You could use parseFilterValue directly and drop parseFilterParam from dependency arrays.


165-165: Long ternary chain is hard to read.

This line packs multiple fallbacks into a single expression. A small lookup or early-continue pattern would be clearer.

Suggested refactor
-      const value = params[filter] || (filter === "domain" ? domain : filter === "tagIds" ? tagIds : filter === "root" ? root : filter === "folderId" ? folderId : undefined);
+      const specialParams: Record<string, string | undefined> = {
+        domain,
+        tagIds,
+        root,
+        folderId,
+      };
+      const value = params[filter] || specialParams[filter];

@vercel
Copy link
Contributor

vercel bot commented Feb 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dub Error Error Feb 10, 2026 6:34am

Request Review

@pepeladeira pepeladeira changed the base branch from main to feat/advanced-filters February 11, 2026 11:08
@pepeladeira pepeladeira merged commit c6ae269 into dubinc:feat/advanced-filters Feb 11, 2026
5 of 6 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.

3 participants