feat(search-performance): add page-path and metric filters to GSC Insights#83
feat(search-performance): add page-path and metric filters to GSC Insights#83shashank-sn wants to merge 2 commits into
Conversation
…ights Users can filter Search Performance by path prefix and impression, click, and position ranges. Path filters push to GSC; metric thresholds apply server-side before pagination and export. Fixes every-app#68
| async function fetchFilteredDimensionRows( | ||
| data: SearchPerformanceMetricFilters & { | ||
| dateRange: SearchPerformanceDateRange; | ||
| dimension: SearchPerformanceTableDimension; | ||
| }, | ||
| contextProjectId: string, | ||
| ) { | ||
| const { startDate, endDate } = resolveDateRange({ | ||
| dateRange: data.dateRange, | ||
| }); | ||
| const { filters } = buildGscFilters(data); | ||
| const result = await GscService.getPerformance({ | ||
| projectId: contextProjectId, | ||
| startDate, | ||
| endDate, | ||
| dimensions: [data.dimension], | ||
| filters, | ||
| rowLimit: EXPORT_ROW_LIMIT, | ||
| }); | ||
| return filterDimensionRowsByMetrics(toDimensionRows(result.rows), data); | ||
| } |
There was a problem hiding this comment.
device and country silently dropped when type is narrowed
fetchFilteredDimensionRows types data as SearchPerformanceMetricFilters & { dateRange, dimension }, which has no device or country. Inside, buildGscFilters(data) is called — it accepts those fields as optional, so TypeScript doesn't complain. Today it works because both callers pass the full validated request object, so the runtime value carries device and country even though the TypeScript type doesn't declare them. If the function is ever called with a genuinely narrower object (or if buildGscFilters is updated to check for the field's presence), those filters silently won't apply and the GSC query will return unfiltered data. The type should declare device?: string; country?: string; so the contract is explicit.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/serverFunctions/searchPerformance.ts
Line: 74-94
Comment:
**`device` and `country` silently dropped when type is narrowed**
`fetchFilteredDimensionRows` types `data` as `SearchPerformanceMetricFilters & { dateRange, dimension }`, which has no `device` or `country`. Inside, `buildGscFilters(data)` is called — it accepts those fields as optional, so TypeScript doesn't complain. Today it works because both callers pass the full validated request object, so the runtime value carries `device` and `country` even though the TypeScript type doesn't declare them. If the function is ever called with a genuinely narrower object (or if `buildGscFilters` is updated to check for the field's presence), those filters silently won't apply and the GSC query will return unfiltered data. The type should declare `device?: string; country?: string;` so the contract is explicit.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Tighten fetchFilteredDimensionRows types, derive metric filters from Zod, and stabilize query keys/prefetch deps with compiled filter state.
Summary
GSC Insights (Search Performance) now supports path-prefix filtering and min/max ranges for impressions, clicks, and average position. Users can isolate sections like
/blogs/*and focus on rows above meaningful thresholds without scanning the full table manually.Path filters are pushed to GSC as
pagecontains filters. Metric ranges are applied server-side after fetch and before pagination/export, so table pages, exports, and striking-distance rows share the same effective filter set. Overview totals honor path/device/country filters; when metric ranges are active, the UI explains that totals exclude those bounds and notes the 1,000-row cap for filtered tables/exports.Validation
pnpm run ci:check— pass (prettier, knip, tsc, oxlint)pnpm run test:ci— 632 tests passManual QA with a connected GSC property is recommended for filter UX and export parity.
Test plan
/blogs/or/blogs/*and confirm queries/pages/striking distance narrow to matching URLsFixes #68
Greptile Summary
This PR adds page-path prefix filtering and min/max metric ranges (impressions, clicks, average position) to the GSC Search Performance feature. Path filters are pushed to GSC as
page containsfilters; metric filters are applied server-side after a capped 1,000-row fetch, with consistent behavior across the overview, paginated table, and CSV/Sheets export.searchPerformanceFilters.tsmodule extracts pure filter/pagination helpers (normalizePagePathFilter,matchesMetricFilters,paginateRows, etc.) with good unit-test coverage.getSearchPerformanceTablegains a metric-filter branch that fetches up to 1,000 rows from GSC, filters in-app, then paginates; without metric filters the original GSC-sidestartRowpagination is preserved.fetchFilteredDimensionRowsis a shared helper used by both the table and export paths, but its parameter type omitsdeviceandcountryeven thoughbuildGscFiltersreads them from the object at runtime — making the device/country filtering implicit and unenforceable by TypeScript.Confidence Score: 3/5
The filtering logic is well-tested in isolation, but the shared
fetchFilteredDimensionRowshelper has a type contract that doesn't capture all of its dependencies, which could silently break device/country filtering if the function is reused or refactored.The core concern is
fetchFilteredDimensionRows: it passes itsdataargument tobuildGscFilters, which readsdeviceandcountryfrom the object at runtime, but those fields are absent from the function's declared parameter type. This works today because both callers pass the full validated request, but TypeScript cannot enforce that invariant. A future caller passing only metric-filter fields would silently produce unscoped GSC queries. The query-key issue (raw form strings instead of compiled filters) is a secondary concern that can cause unnecessary cache churn when users type numeric values.src/serverFunctions/searchPerformance.ts — the
fetchFilteredDimensionRowssignature and its implicit dependency on runtime properties not reflected in its type.Important Files Changed
getSearchPerformanceTableand refactors export to usefetchFilteredDimensionRows; the helper's type signature omitsdevice/countryeven thoughbuildGscFiltersreads them at runtime, creating a hidden contract.refineMetricRangescross-field validation, andhasActiveMetricFilters;SearchPerformanceMetricFiltersis a manually-maintained parallel type instead of being derived viaz.infer.normalizePagePathFilter,toPagePathGscFilter,matchesMetricFilters,filterDimensionRowsByMetrics,filterStrikingDistanceByMetrics, andpaginateRows.Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant UI as SearchPerformancePage participant SF as searchPerformance (serverFn) participant Filters as searchPerformanceFilters participant GSC as GscService UI->>SF: "getSearchPerformanceReport({ pagePath, device, country, ... })" SF->>SF: buildGscFilters(data) → deviceFilters + filters (incl. pagePath) SF->>GSC: getPerformance(filters) × 3 (current, previous, queryPages) GSC-->>SF: raw rows SF->>Filters: filterStrikingDistanceByMetrics(rows, metricFilters) SF-->>UI: "{ totals, strikingDistance, metricFiltersActive }" UI->>SF: "getSearchPerformanceTable({ page, pageSize, metric filters... })" alt hasActiveMetricFilters SF->>Filters: fetchFilteredDimensionRows(data, projectId) Filters->>GSC: "getPerformance(filters, rowLimit=1000)" GSC-->>Filters: up to 1000 rows Filters->>Filters: filterDimensionRowsByMetrics(rows, metricFilters) Filters->>Filters: paginateRows(filtered, page, pageSize) Filters-->>SF: "{ rows, hasNextPage }" else no metric filters SF->>GSC: "getPerformance(filters, startRow=offset, rowLimit=pageSize+1)" GSC-->>SF: page rows end SF-->>UI: "{ rows, hasNextPage }" UI->>SF: "exportSearchPerformanceTable({ dimension, filters... })" SF->>Filters: fetchFilteredDimensionRows(data, projectId) Filters->>GSC: "getPerformance(filters, rowLimit=1000)" GSC-->>Filters: rows Filters->>Filters: filterDimensionRowsByMetrics SF-->>UI: "{ dimension, rows }"%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant UI as SearchPerformancePage participant SF as searchPerformance (serverFn) participant Filters as searchPerformanceFilters participant GSC as GscService UI->>SF: "getSearchPerformanceReport({ pagePath, device, country, ... })" SF->>SF: buildGscFilters(data) → deviceFilters + filters (incl. pagePath) SF->>GSC: getPerformance(filters) × 3 (current, previous, queryPages) GSC-->>SF: raw rows SF->>Filters: filterStrikingDistanceByMetrics(rows, metricFilters) SF-->>UI: "{ totals, strikingDistance, metricFiltersActive }" UI->>SF: "getSearchPerformanceTable({ page, pageSize, metric filters... })" alt hasActiveMetricFilters SF->>Filters: fetchFilteredDimensionRows(data, projectId) Filters->>GSC: "getPerformance(filters, rowLimit=1000)" GSC-->>Filters: up to 1000 rows Filters->>Filters: filterDimensionRowsByMetrics(rows, metricFilters) Filters->>Filters: paginateRows(filtered, page, pageSize) Filters-->>SF: "{ rows, hasNextPage }" else no metric filters SF->>GSC: "getPerformance(filters, startRow=offset, rowLimit=pageSize+1)" GSC-->>SF: page rows end SF-->>UI: "{ rows, hasNextPage }" UI->>SF: "exportSearchPerformanceTable({ dimension, filters... })" SF->>Filters: fetchFilteredDimensionRows(data, projectId) Filters->>GSC: "getPerformance(filters, rowLimit=1000)" GSC-->>Filters: rows Filters->>Filters: filterDimensionRowsByMetrics SF-->>UI: "{ dimension, rows }"Comments Outside Diff (1)
src/client/features/search-performance/SearchPerformancePage.tsx, line 476-485 (link)advancedFiltersin the query key holds raw string values (e.g."100","100.0","abc"). Two form states that compile to identicalSearchPerformanceMetricFilters— such as"10"vs"10.0", or a partially invalid form that compiles the same as an empty one — produce different cache entries and trigger separate network calls. UsingcompileAdvancedSearchPerformanceFilters(advancedFilters)as the key value gives TanStack Query a stable semantic identity that matches what the server actually receives.Prompt To Fix With AI
Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "feat(search-performance): add page-path ..." | Re-trigger Greptile