Skip to content

Conversation

@lindesvard
Copy link
Contributor

@lindesvard lindesvard commented Dec 18, 2025

Summary by CodeRabbit

  • New Features
    • Added Insights: automated traffic analysis with per-project insight cards (impact, deltas, severity).
    • Insights dashboard: overview component on project dashboard and a dedicated Insights page with search, filters (severity, window, direction), sorting, and per-module carousels.
    • Insight cards: metric comparisons, severity badges, and filter actions from cards.
    • Navigation: new Insights link in the sidebar.
    • Background processing: scheduled daily insights generation for projects.

✏️ Tip: You can customize this high-level summary in your review settings.

@vercel
Copy link

vercel bot commented Dec 18, 2025

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

Project Deployment Review Updated (UTC)
openpanel-public Error Error Dec 19, 2025 8:26am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 18, 2025

Walkthrough

Adds a full "insights" feature: frontend UI and route, TRPC endpoints, DB schema and migrations, a compute engine with multiple analysis modules, worker/cron jobs and queues, and supporting utilities for scoring, caching, and persistence.

Changes

Cohort / File(s) Summary
Frontend: Insight UI
apps/start/src/components/insights/insight-card.tsx, apps/start/src/components/overview/overview-insights.tsx
New InsightCard component and OverviewInsights carousel; fetches insights via TRPC, renders cards, supports onFilter callback and metric/delta display.
Frontend: Routes & Integration
apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx, apps/start/src/routeTree.gen.ts, apps/start/src/routes/_app.$organizationId.$projectId.index.tsx
New /insights page route and wiring in generated route tree; OverviewInsights added to project dashboard.
Frontend: Nav & Small UI Tweaks
apps/start/src/components/sidebar-project-menu.tsx, apps/start/src/components/sidebar.tsx, apps/start/src/components/ui/carousel.tsx, apps/start/src/utils/title.ts
Added "Insights" sidebar link/icon, hide-scrollbar class on sidebar, small carousel class cleanup, and PAGE_TITLES.INSIGHTS.
DB Schema & Migrations
packages/db/prisma/schema.prisma, packages/db/prisma/migrations/*insights*/migration.sql
New InsightState enum, ProjectInsight and InsightEvent models; migrations to create tables, enforce non-null payload default, and add displayName (with drops of some columns).
Insights Engine & Modules
packages/db/src/services/insights/* (engine.ts, material.ts, scoring.ts, windows.ts, utils.ts, cached-clix.ts, modules/*.ts, store.ts, index.ts, types.ts)
New engine orchestration, module implementations (referrers, entry-pages, page-trends, geo, devices), scoring, materialization logic, cached ClickHouse wrapper, utilities, and Prisma-backed InsightStore.
DB: ClickHouse & Prisma changes
packages/db/src/clickhouse/query-builder.ts, packages/db/src/prisma-client.ts, packages/db/src/types.ts
Query builder now accepts Expression in SELECT; runtime Prisma CRUD logging disabled; PrismaJson type alias for insight payload added.
DB: Migrations & Data Guards
packages/db/code-migrations/4-add-sessions.ts, packages/db/code-migrations/8-order-keys.ts
Added guards to treat epoch (1970) timestamps as null to avoid invalid migration behavior.
Queue, Worker & Cron
packages/queue/src/queues.ts, apps/worker/src/boot-cron.ts, apps/worker/src/boot-workers.ts, apps/worker/src/jobs/cron.ts, apps/worker/src/jobs/insights.ts, apps/worker/src/index.ts
New insightsQueue type and constant; added insightsDaily cron job, worker startup for insights, insightsDailyJob and insightsProjectJob implementations, and Bull Board wiring for insightsQueue.
API & Validation
packages/trpc/src/routers/insight.ts, packages/trpc/src/root.ts, packages/validation/src/types.insights.ts, packages/validation/index.ts, packages/validation/src/index.ts
New TRPC insightRouter with list/listAll procedures; router wired into appRouter; new InsightPayload types exported from validation package.
Exports & Packaging
packages/db/index.ts, packages/db/src/services/insights/index.ts, packages/validation/index.ts
Re-exports added to surface insights services and validation types.
Build script
sh/docker-build
Docker tag logic adjusted: full_version omits prerelease suffix; prerelease builds use separate -PRERELEASE tag format.

Sequence Diagram(s)

sequenceDiagram
    participant UI as Frontend UI
    participant API as TRPC API
    participant Worker as Cron/Worker
    participant Engine as Insights Engine
    participant CH as ClickHouse
    participant Store as InsightStore (Postgres)

    UI->>API: request insights (list / listAll)
    API->>Store: query active insights (project)
    Store->>Store: map/prepare payload
    Store-->>API: insights JSON
    API-->>UI: return insights
    UI->>UI: render InsightCard carousel

    Worker->>Worker: cron triggers insightsDaily
    Worker->>Store: listProjectIdsForCadence('daily')
    Store-->>Worker: project IDs

    loop per project
        Worker->>Worker: enqueue insightsProjectJob(projectId, date)
        Worker->>Engine: runProject(projectId, cadence)
        loop per module/window
            Engine->>CH: fetch current & baseline aggregates
            CH-->>Engine: aggregated rows
            Engine->>Engine: computeMany -> RenderedCard & score
            Engine->>Store: upsertInsight(card, metrics, decision)
            Store->>Store: insert/update ProjectInsight, maybe insert InsightEvent
        end
        Engine->>Store: closeMissingActiveInsights(...)
        Engine->>Store: applySuppression(...)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • Areas needing careful review:
    • packages/db/src/services/insights/engine.ts — orchestration, batching, error handling, and upsert/event semantics.
    • packages/db/src/services/insights/store.ts — Prisma queries, state transitions (active/suppressed/closed), uniqueness/threadId handling.
    • modules in packages/db/src/services/insights/modules/*.ts — heterogeneous domain logic per module (referrers, geo, devices, page trends, entry pages).
    • DB migrations and schema changes — verify index/constraint intent and potential data-migration impacts.
    • packages/trpc/src/routers/insight.ts — deduplication by windowKind priority and response shaping.

Possibly related PRs

Poem

🐰 I nibble bytes beneath moonlight's gleam,

Insights sprout softly—charts and dream.
Workers hum, modules hop in tune,
Dashboards sparkle by afternoon.
A crunchy carrot for data's delight!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.53% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Feature/insights cards' accurately describes the main feature being introduced—a new insights card component system with related UI and backend infrastructure.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/insights-cards

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
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: 9

Caution

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

⚠️ Outside diff range comments (3)
packages/db/code-migrations/8-order-keys.ts (2)

142-170: CRITICAL: Data loss risk when guard prevents migration.

If the guard condition fails (created_at is missing or starts with '1970'), the data copy is skipped but table rename operations at lines 250-284 still execute unconditionally. This would:

  1. Create an empty events_new_20251123 table
  2. Rename the populated events table to events_20251123 (backup)
  3. Rename the empty events_new_20251123 to events (production)

Result: Complete data loss — the production events table becomes empty while data sits in a backup table.

🔎 Apply this diff to skip the entire migration when guards fail:
+  let shouldMigrate = true;
+
   const firstEventDateResponse = await chMigrationClient.query({
     query: 'SELECT min(created_at) as created_at FROM events',
     format: 'JSONEachRow',
   });
   const firstEventDateJson = await firstEventDateResponse.json<{
     created_at: string;
   }>();
   if (
     firstEventDateJson[0]?.created_at &&
     !firstEventDateJson[0]?.created_at.startsWith('1970')
   ) {
     const firstEventDate = new Date(firstEventDateJson[0]?.created_at);
     // Step 2: Copy data from old tables to new tables (partitioned by month for efficiency)
     // Set endDate to first of next month to ensure we capture all data in the current month
     const endDate = new Date();
     endDate.setMonth(endDate.getMonth() + 1);
     endDate.setDate(1);

     sqls.push(
       ...moveDataBetweenTables({
         from: 'events',
         to: 'events_new_20251123',
         batch: {
           startDate: firstEventDate,
           endDate: endDate,
           column: 'toDate(created_at)',
           interval: 'month',
           transform: (date: Date) => {
             const year = date.getFullYear();
             const month = String(date.getMonth() + 1).padStart(2, '0');
             return `${year}-${month}-01`;
           },
         },
       }),
     );
+  } else {
+    console.warn('Skipping events migration: invalid or missing created_at');
+    shouldMigrate = false;
   }

   const firstSessionDateResponse = await chMigrationClient.query({
     query: 'SELECT min(created_at) as created_at FROM sessions',
     format: 'JSONEachRow',
   });
   const firstSessionDateJson = await firstSessionDateResponse.json<{
     created_at: string;
   }>();

   if (
     firstSessionDateJson[0]?.created_at &&
     !firstSessionDateJson[0]?.created_at.startsWith('1970')
   ) {
     const firstSessionDate = new Date(
       firstSessionDateJson[0]?.created_at ?? '',
     );
     // Set endDate to first of next month to ensure we capture all data in the current month
     const endDate = new Date();
     endDate.setMonth(endDate.getMonth() + 1);
     endDate.setDate(1);

     sqls.push(
       ...moveDataBetweenTables({
         from: 'sessions',
         to: 'sessions_new_20251123',
         columns: [
           // ... columns
         ],
         batch: {
           startDate: firstSessionDate,
           endDate: endDate,
           column: 'toDate(created_at)',
           interval: 'month',
           transform: (date: Date) => {
             const year = date.getFullYear();
             const month = String(date.getMonth() + 1).padStart(2, '0');
             return `${year}-${month}-01`;
           },
         },
       }),
     );
+  } else {
+    console.warn('Skipping sessions migration: invalid or missing created_at');
+    shouldMigrate = false;
   }
+
+  if (!shouldMigrate) {
+    console.error('Migration aborted due to invalid source data');
+    return;
+  }

   sqls.push(
     ...renameTable({ from: 'events', to: 'events_20251123', isClustered }),
   );
   // ... rest of rename operations

180-248: CRITICAL: Identical data loss risk for sessions table.

This guard has the same fatal flaw as the events migration: if the condition fails, data copying is skipped but table rename operations (lines 250-284) proceed unconditionally, resulting in an empty production sessions table.

Additionally, Line 185 uses nullish coalescing (?? '') when constructing the Date, but this is inconsistent with the events migration (Line 146) which omits it. Since the guard already verified created_at exists, the fallback is unnecessary.

Apply the same fix suggested in the previous comment to abort the entire migration when either guard fails.

sh/docker-build (1)

70-76: Logging messages don't reflect the actual tag for prerelease builds.

Lines 71 and 75 always reference $full_version, but for prerelease builds, the actual tag pushed is $full_version-$PRERELEASE. This causes the success/failure messages to display an incomplete tag name for prerelease builds.

🔎 Apply this diff to fix the logging:
+    local tag_name="$full_version"
+    if [ -n "$PRERELEASE" ]; then
+        tag_name="$full_version-$PRERELEASE"
+    fi
+    
     if [ $? -ne 0 ]; then
-        echo "Failed to build $full_version"
+        echo "Failed to build $tag_name"
         exit 1
     fi
     
-    echo "Successfully built and pushed multi-architecture image for $full_version"
+    echo "Successfully built and pushed multi-architecture image for $tag_name"
 }
🧹 Nitpick comments (30)
sh/docker-build (3)

41-41: Consider renaming full_version for clarity.

The variable full_version contains the complete Docker image reference (registry/repository:tag), not just the version. A name like base_tag or image_tag would be more accurate and improve maintainability.

🔎 Suggested refactor:
-    local full_version="$image_name:$VERSION"
+    local base_tag="$image_name:$VERSION"

Then update all references to full_version throughout the function to use base_tag instead.


21-25: Consider showing the complete tag in the prerelease prompt.

For prerelease builds, the prompt shows only $VERSION, but the actual tag will be $VERSION-$PRERELEASE. Displaying the complete tag improves clarity and helps operators verify they're building the correct version.

🔎 Suggested improvement:
 if [ -z "$PRERELEASE" ]; then
     read -p "You're building WITHOUT any pre-release ($VERSION). Do you wish to continue (y/N)? " -n 1 -r
 else
-    read -p "You're building WITH pre-release ($VERSION). Do you wish to continue (y/N)? " -n 1 -r
+    read -p "You're building WITH pre-release ($VERSION-$PRERELEASE). Do you wish to continue (y/N)? " -n 1 -r
 fi

5-5: Add input validation for the PRERELEASE parameter.

The PRERELEASE parameter is used directly in Docker tag construction without validation. Docker tags must contain only alphanumeric characters, dots, underscores, and hyphens, with a maximum of 128 characters. Invalid input will cause the docker buildx command to fail with a cryptic error.

🔎 Add validation after line 18:
     exit 1
 fi
+
+# Validate PRERELEASE format if provided
+if [ -n "$PRERELEASE" ]; then
+    if [[ ! "$PRERELEASE" =~ ^[a-zA-Z0-9._-]+$ ]]; then
+        echo "Error: PRERELEASE tag '$PRERELEASE' contains invalid characters."
+        echo "Only alphanumeric characters, dots, underscores, and hyphens are allowed."
+        exit 1
+    fi
+    if [ ${#PRERELEASE} -gt 128 ]; then
+        echo "Error: PRERELEASE tag is too long (max 128 characters)."
+        exit 1
+    fi
+fi
 
 # Add warning prompt
packages/db/src/clickhouse/query-builder.ts (1)

37-43: Consider adding JSDoc to document Expression security implications.

The Expression class and clix.exp() function allow raw SQL to bypass escaping, which is powerful but risky. Consider adding JSDoc comments to warn developers:

🔎 Suggested documentation additions:
+/**
+ * Represents a raw SQL expression that bypasses escaping.
+ * ⚠️ WARNING: Never pass user input to Expression. Only use with trusted SQL literals.
+ */
 class Expression {
   constructor(private expression: string) {}
 
   toString() {
     return this.expression;
   }
 }
+/**
+ * Creates a raw SQL expression that bypasses escaping.
+ * @param expr - A SQL expression string or Query object
+ * @returns An Expression object
+ * ⚠️ WARNING: Never pass user-supplied input to this function.
+ * Only use with trusted SQL literals to prevent SQL injection.
+ */
 clix.exp = (expr: string | Query<any>) =>
   new Expression(expr instanceof Query ? expr.toSQL() : expr);

Also applies to: 663-664

packages/constants/index.ts (1)

501-503: Consider safer type handling for invalid country codes.

The getCountry function uses type assertion (as keyof typeof countries) which bypasses TypeScript's type checking. For invalid or undefined codes, this will return undefined at runtime, but the return type doesn't reflect this possibility.

🔎 Apply this diff for safer type handling:
-export function getCountry(code?: string) {
-  return countries[code as keyof typeof countries];
+export function getCountry(code?: string): string | undefined {
+  if (!code) return undefined;
+  return countries[code as keyof typeof countries];
 }

This makes the return type explicit and adds a guard for undefined input, improving type safety and making the behavior clearer to consumers.

packages/db/prisma/schema.prisma (1)

507-541: Consider nullable displayName instead of empty string default.

The schema is well-structured with appropriate indices and constraints. However, displayName has @default("") which may create ambiguity between "not set" and "intentionally empty".

🔎 Consider this alternative:
-  displayName String  @default("")
+  displayName String?

This makes the "not set" state explicit and aligns with other optional string fields like summary and direction. However, if your application logic specifically requires empty strings over null, the current approach is acceptable.

packages/db/src/session-consistency.ts (1)

183-187: Consider the observability impact of removing this logging.

The Prisma write operation logging has been commented out, which reduces visibility into database mutations. If this was removed to reduce log volume, consider:

  1. Using a higher log level (debug) instead of removing it entirely
  2. Adding an environment variable to conditionally enable detailed operation logging
  3. Documenting why this logging was removed
packages/db/src/prisma-client.ts (1)

45-49: Consider the observability impact of removing this logging.

Similar to the change in session-consistency.ts, this removes logging for Prisma operations (create, update, delete). This reduces visibility into database mutations across the application. If log volume is a concern, consider using conditional logging or higher log levels rather than complete removal.

packages/db/src/services/insights/windows.ts (1)

44-58: Consider adding exhaustive type checking for WindowKind.

The default block silently handles rolling_30d, but if a new WindowKind is added to the type, this function will silently fall through without a compile-time error. Consider using an exhaustive check pattern.

🔎 Suggested improvement
-  // rolling_30d
-  {
+  if (kind === 'rolling_30d') {
     const end = yesterday0;
     const start = addDays(end, -29);
     const baselineEnd = addDays(start, -1);
     const baselineStart = addDays(baselineEnd, -29);
     return {
       kind,
       start,
       end,
       baselineStart,
       baselineEnd,
       label: 'Last 30 days',
     };
   }
+
+  // Exhaustive check - TypeScript will error if WindowKind is extended
+  const _exhaustive: never = kind;
+  throw new Error(`Unknown window kind: ${_exhaustive}`);
apps/worker/src/jobs/insights.ts (1)

29-45: Unused job parameter in insightsDailyJob.

The job parameter is declared but never used. If you need access to job metadata for logging or other purposes, consider using it; otherwise, prefix with underscore to indicate intentional non-use.

🔎 Suggested fix
-export async function insightsDailyJob(job: Job<CronQueuePayload>) {
+export async function insightsDailyJob(_job: Job<CronQueuePayload>) {
packages/db/src/services/insights/material.ts (2)

17-18: Avoid as any type assertions.

The as any casts bypass TypeScript's type checking. Since PersistedInsight.direction is typed as string | null, consider narrowing with a type guard or updating the interface to use a union type.

🔎 Suggested approach
-  const prevDir = (prev.direction ?? 'flat') as any;
-  const nextDir = next.direction ?? 'flat';
+  const prevDir = prev.direction ?? 'flat';
+  const nextDir: string = next.direction ?? 'flat';
   if (prevDir !== nextDir && (nextDir === 'up' || nextDir === 'down')) {

Alternatively, consider updating PersistedInsight.direction in types.ts to use 'up' | 'down' | 'flat' | null instead of string | null for stricter typing.


27-35: Same as any pattern for severityBand.

Apply the same type narrowing approach here for consistency.

🔎 Suggested fix
-  const prevBand = (prev.severityBand ?? null) as any;
+  const prevBand = prev.severityBand ?? null;
apps/start/src/components/overview/overview-insights.tsx (1)

20-26: Unused filters variable.

The filters value from useEventQueryFilters() is destructured but never used. Consider removing it if not needed.

🔎 Suggested fix
-  const [filters, setFilter] = useEventQueryFilters();
+  const [, setFilter] = useEventQueryFilters();
packages/db/src/services/insights/modules/devices.module.ts (1)

260-266: Extract magic number as named constant.

The threshold 0.0005 for share direction detection should be a named constant for clarity and maintainability.

🔎 Suggested fix
+const SHARE_DIRECTION_THRESHOLD = 0.0005;
+
 // In render function:
             direction:
-              shareCurrent - shareCompare > 0.0005
+              shareCurrent - shareCompare > SHARE_DIRECTION_THRESHOLD
                 ? 'up'
-                : shareCurrent - shareCompare < -0.0005
+                : shareCurrent - shareCompare < -SHARE_DIRECTION_THRESHOLD
                   ? 'down'
                   : 'flat',
packages/queue/src/queues.ts (1)

248-256: Consider adding removeOnFail for consistency with importQueue.

The insightsQueue is well-structured with removeOnComplete: 100 for extended job retention. However, importQueue includes removeOnFail: 50 to preserve failed jobs for debugging. Adding a similar removeOnFail option would help with troubleshooting failed insight jobs and align with the pattern used in importQueue.

packages/trpc/src/routers/insight.ts (1)

30-32: Hardcoded module exclusion may need documentation.

The list procedure excludes page-trends and entry-pages modules. Consider adding a comment explaining why these modules are excluded from the main list, or extracting this to a constant for easier maintenance.

🔎 Suggested improvement:
+// These modules are excluded from the main insights list
+// because they may generate too many similar cards
+const EXCLUDED_MODULES = ['page-trends', 'entry-pages'];
+
 const allInsights = await db.projectInsight.findMany({
   where: {
     projectId,
     state: 'active',
     moduleKey: {
-      notIn: ['page-trends', 'entry-pages'],
+      notIn: EXCLUDED_MODULES,
     },
   },
packages/db/src/services/insights/modules/referrers.module.ts (1)

259-264: Hardcoded share direction threshold differs from pattern in other metrics.

The share direction calculation uses a hardcoded threshold of 0.0005 (0.05%), while other modules use computeDirection with a default threshold. Consider extracting this to a constant or using a consistent approach.

🔎 Suggested improvement:
+const SHARE_DIRECTION_THRESHOLD = 0.0005; // 0.05%
+
 direction:
-  shareCurrent - shareCompare > 0.0005
+  shareCurrent - shareCompare > SHARE_DIRECTION_THRESHOLD
     ? 'up'
-    : shareCurrent - shareCompare < -0.0005
+    : shareCurrent - shareCompare < -SHARE_DIRECTION_THRESHOLD
       ? 'down'
       : 'flat',
packages/db/src/services/insights/modules/entry-pages.module.ts (1)

20-172: Significant code duplication with page-trends.module.ts.

The fetchEntryPageAggregates function is structurally almost identical to fetchPageTrendAggregates in page-trends.module.ts, differing only in:

  • Table source (sessions vs events)
  • Column names (entry_origin/entry_path vs origin/path)
  • Event filter (sign=1 vs name='screen_view')

Consider extracting a generic aggregation helper to reduce duplication. This is a recommended refactor that could be deferred.

#!/bin/bash
# Show the similarity between the two fetch functions
echo "=== page-trends fetchPageTrendAggregates ==="
rg -A 30 "async function fetchPageTrendAggregates" --type ts
echo ""
echo "=== entry-pages fetchEntryPageAggregates ==="
rg -A 30 "async function fetchEntryPageAggregates" --type ts
packages/db/src/services/insights/engine.ts (3)

59-68: Consider stronger typing for db parameter.

The db parameter is typed as any, which loses type safety. Consider using the actual database client type from your Prisma setup.

🔎 Suggested improvement
+import type { PrismaClient } from '../../prisma-client';
+
 export function createEngine(args: {
   store: InsightStore;
   modules: InsightModule[];
-  db: any;
+  db: PrismaClient;
   logger?: Pick<Console, 'info' | 'warn' | 'error'>;
   config: EngineConfig;
 }) {

193-198: Potential N+1 query pattern for previous insight lookups.

Each result triggers a separate getActiveInsightByIdentity call. For high-volume scenarios, consider batch-fetching previous insights for all dimensionKeys in the batch before the loop.

The current approach is acceptable given the maxDims cap (default 25), but batching could reduce database round-trips if performance becomes a concern.


246-254: Consider extracting event kind determination to a helper function.

The nested ternary for computing eventKind is functional but hard to read. A small helper would improve clarity.

🔎 Suggested refactor
function determineEventKind(
  decision: { reason: string },
  sev: string | null,
  prevSeverity: string | null
): string {
  if (decision.reason === 'direction_flip') return 'direction_flip';
  if (decision.reason === 'severity_change') {
    return sev && prevSeverity && sev > prevSeverity ? 'severity_up' : 'severity_down';
  }
  return 'updated';
}
packages/db/src/services/insights/modules/geo.module.ts (1)

256-261: Consider extracting the share direction threshold as a named constant.

The magic number 0.0005 for share direction determination is used inline. A named constant would improve readability and make it easier to tune.

🔎 Suggested improvement
+const SHARE_DIRECTION_THRESHOLD = 0.0005;
+
 // In render function:
 direction:
-  shareCurrent - shareCompare > 0.0005
+  shareCurrent - shareCompare > SHARE_DIRECTION_THRESHOLD
     ? 'up'
-    : shareCurrent - shareCompare < -0.0005
+    : shareCurrent - shareCompare < -SHARE_DIRECTION_THRESHOLD
       ? 'down'
       : 'flat',
packages/db/src/services/insights/utils.ts (1)

40-48: Type safety could be improved with generics constraint.

The (row as any).date and (row as any).cnt casts bypass type checking. Consider adding a constraint to the generic type T to require these properties.

🔎 Suggested improvement
-export function computeWeekdayMedians<T>(
+interface DailyRow {
+  date: string;
+  cnt: number;
+}
+
+export function computeWeekdayMedians<T extends DailyRow>(
   data: T[],
   targetWeekday: number,
   getDimension: (row: T) => string,
 ): Map<string, number> {
   // ...
   for (const row of data) {
-    const rowWeekday = getWeekday(new Date((row as any).date));
+    const rowWeekday = getWeekday(new Date(row.date));
     // ...
-    values.push(Number((row as any).cnt ?? 0));
+    values.push(Number(row.cnt ?? 0));
apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx (2)

171-189: Duplicate severityOrder map definition.

The severityOrder record is defined identically in both severity-desc and severity-asc cases. Extract it outside the switch for DRY.

🔎 Suggested improvement
+const SEVERITY_ORDER: Record<string, number> = {
+  severe: 3,
+  moderate: 2,
+  low: 1,
+};
+
 // Inside the sort function:
 case 'severity-desc': {
-  const severityOrder: Record<string, number> = {
-    severe: 3,
-    moderate: 2,
-    low: 1,
-  };
-  const aSev = severityOrder[a.severityBand ?? ''] ?? 0;
-  const bSev = severityOrder[b.severityBand ?? ''] ?? 0;
+  const aSev = SEVERITY_ORDER[a.severityBand ?? ''] ?? 0;
+  const bSev = SEVERITY_ORDER[b.severityBand ?? ''] ?? 0;
   return bSev - aSev;
 }
 case 'severity-asc': {
-  const severityOrder: Record<string, number> = { ... };
-  const aSev = severityOrder[a.severityBand ?? ''] ?? 0;
-  const bSev = severityOrder[b.severityBand ?? ''] ?? 0;
+  const aSev = SEVERITY_ORDER[a.severityBand ?? ''] ?? 0;
+  const bSev = SEVERITY_ORDER[b.severityBand ?? ''] ?? 0;
   return aSev - bSev;
 }

391-410: Consider simplifying the onFilter callback creation.

The IIFE pattern for creating onFilter works but is somewhat unusual. A simpler approach would be a helper function or inline conditional.

🔎 Alternative approach
const getFilterHandler = (insight: typeof moduleInsights[0]) => {
  const filterString = insight.payload?.dimensions
    .map((dim) => `${dim.key},is,${encodeURIComponent(dim.value)}`)
    .join(';');
  
  if (!filterString) return undefined;
  
  return () => {
    navigate({
      to: '/$organizationId/$projectId',
      from: Route.fullPath,
      search: { f: filterString },
    });
  };
};

// Usage:
<InsightCard
  insight={insight}
  onFilter={getFilterHandler(insight)}
/>
packages/db/src/services/insights/store.ts (2)

269-275: Individual updates in loop could be batched.

The suppression logic performs individual db.projectInsight.update calls in loops. For better performance at scale, consider batching these updates.

🔎 Suggested approach for stale insights
// Instead of looping with individual updates:
const staleIds = insights
  .filter(i => isStale(i) && i.state === 'active')
  .map(i => i.id);

if (staleIds.length > 0) {
  const result = await db.projectInsight.updateMany({
    where: { id: { in: staleIds } },
    data: { state: 'suppressed', lastUpdatedAt: now },
  });
  suppressed += result.count;
}

Apply similar batching for belowN and topN updates.


113-155: Type safety lost with any declaration.

The insight variable is typed as any, losing Prisma's type inference from the create/update operations. Consider using the actual Prisma type.

🔎 Suggested fix
-let insight: any;
+let insight: Awaited<ReturnType<typeof db.projectInsight.create>>;

Or import the generated Prisma type:

import type { ProjectInsight } from '@prisma/client';
// ...
let insight: ProjectInsight;
packages/db/prisma/migrations/20251212192459_insights/migration.sql (2)

50-51: Note: Unique constraint includes state — allows multiple rows per logical insight.

This design enables keeping historical closed/suppressed rows while creating new active ones. Consider adding a periodic cleanup job to archive or delete old closed insights to prevent table bloat over time.


5-30: Add foreign key constraint to maintain consistency with other tables.

The project_insights table lacks a foreign key constraint on projectId, while similar tables (imports, chats) define FOREIGN KEY (projectId) REFERENCES projects(id) ON DELETE CASCADE. To maintain referential integrity and codebase consistency, add:

ALTER TABLE "public"."project_insights" ADD CONSTRAINT "project_insights_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
packages/db/src/services/insights/types.ts (1)

21-33: Consider adding a more specific type for db instead of any.

Using any for the database client loses type safety. If this is a Prisma client, consider:

 export interface ComputeContext {
   projectId: string;
   window: WindowRange;
-  db: any; // your DB client
+  db: import('@prisma/client').PrismaClient; // or a more specific subset interface
   now: Date;
   logger: Pick<Console, 'info' | 'warn' | 'error'>;

Alternatively, if the engine needs to remain DB-agnostic, define a minimal interface with just the methods used.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1e4f02f and 5757cb2.

📒 Files selected for processing (48)
  • apps/start/src/components/insights/insight-card.tsx (1 hunks)
  • apps/start/src/components/overview/overview-insights.tsx (1 hunks)
  • apps/start/src/components/sidebar-project-menu.tsx (2 hunks)
  • apps/start/src/components/sidebar.tsx (1 hunks)
  • apps/start/src/components/ui/carousel.tsx (1 hunks)
  • apps/start/src/routeTree.gen.ts (11 hunks)
  • apps/start/src/routes/_app.$organizationId.$projectId.index.tsx (2 hunks)
  • apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx (1 hunks)
  • apps/start/src/utils/title.ts (1 hunks)
  • apps/worker/src/boot-cron.ts (1 hunks)
  • apps/worker/src/boot-workers.ts (4 hunks)
  • apps/worker/src/index.ts (2 hunks)
  • apps/worker/src/jobs/cron.ts (2 hunks)
  • apps/worker/src/jobs/insights.ts (1 hunks)
  • packages/constants/index.ts (1 hunks)
  • packages/db/code-migrations/4-add-sessions.ts (1 hunks)
  • packages/db/code-migrations/8-order-keys.ts (2 hunks)
  • packages/db/index.ts (1 hunks)
  • packages/db/prisma/migrations/20251212192459_insights/migration.sql (1 hunks)
  • packages/db/prisma/migrations/20251217204808_insight_payload_default/migration.sql (1 hunks)
  • packages/db/prisma/migrations/20251217210920_insights/migration.sql (1 hunks)
  • packages/db/prisma/schema.prisma (1 hunks)
  • packages/db/src/clickhouse/query-builder.ts (3 hunks)
  • packages/db/src/prisma-client.ts (1 hunks)
  • packages/db/src/services/insights/cached-clix.ts (1 hunks)
  • packages/db/src/services/insights/engine.ts (1 hunks)
  • packages/db/src/services/insights/index.ts (1 hunks)
  • packages/db/src/services/insights/material.ts (1 hunks)
  • packages/db/src/services/insights/modules/devices.module.ts (1 hunks)
  • packages/db/src/services/insights/modules/entry-pages.module.ts (1 hunks)
  • packages/db/src/services/insights/modules/geo.module.ts (1 hunks)
  • packages/db/src/services/insights/modules/index.ts (1 hunks)
  • packages/db/src/services/insights/modules/page-trends.module.ts (1 hunks)
  • packages/db/src/services/insights/modules/referrers.module.ts (1 hunks)
  • packages/db/src/services/insights/scoring.ts (1 hunks)
  • packages/db/src/services/insights/store.ts (1 hunks)
  • packages/db/src/services/insights/types.ts (1 hunks)
  • packages/db/src/services/insights/utils.ts (1 hunks)
  • packages/db/src/services/insights/windows.ts (1 hunks)
  • packages/db/src/session-consistency.ts (1 hunks)
  • packages/db/src/types.ts (2 hunks)
  • packages/queue/src/queues.ts (2 hunks)
  • packages/trpc/src/root.ts (2 hunks)
  • packages/trpc/src/routers/insight.ts (1 hunks)
  • packages/validation/index.ts (1 hunks)
  • packages/validation/src/index.ts (1 hunks)
  • packages/validation/src/types.insights.ts (1 hunks)
  • sh/docker-build (2 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (.cursorrules)

When writing ClickHouse queries, always use the custom query builder from ./packages/db/src/clickhouse/query-builder.ts and ./packages/db/src/clickhouse/query-functions.ts

Files:

  • packages/db/src/services/insights/modules/index.ts
  • packages/validation/src/index.ts
  • packages/db/src/prisma-client.ts
  • packages/db/index.ts
  • apps/start/src/routes/_app.$organizationId.$projectId.index.tsx
  • apps/start/src/components/insights/insight-card.tsx
  • packages/trpc/src/root.ts
  • packages/db/src/services/insights/index.ts
  • packages/db/src/services/insights/windows.ts
  • packages/validation/index.ts
  • apps/start/src/components/overview/overview-insights.tsx
  • apps/worker/src/boot-cron.ts
  • packages/db/src/session-consistency.ts
  • packages/db/src/services/insights/material.ts
  • apps/worker/src/jobs/cron.ts
  • apps/start/src/components/sidebar-project-menu.tsx
  • packages/db/src/services/insights/modules/geo.module.ts
  • apps/start/src/components/sidebar.tsx
  • apps/worker/src/jobs/insights.ts
  • packages/db/src/services/insights/modules/entry-pages.module.ts
  • packages/db/src/services/insights/cached-clix.ts
  • packages/trpc/src/routers/insight.ts
  • packages/db/src/services/insights/modules/referrers.module.ts
  • packages/constants/index.ts
  • apps/start/src/components/ui/carousel.tsx
  • packages/db/src/services/insights/scoring.ts
  • packages/db/code-migrations/8-order-keys.ts
  • packages/db/src/services/insights/utils.ts
  • packages/db/src/services/insights/modules/page-trends.module.ts
  • packages/db/code-migrations/4-add-sessions.ts
  • packages/db/src/clickhouse/query-builder.ts
  • apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx
  • apps/start/src/utils/title.ts
  • packages/db/src/services/insights/store.ts
  • apps/worker/src/index.ts
  • packages/queue/src/queues.ts
  • packages/validation/src/types.insights.ts
  • packages/db/src/services/insights/engine.ts
  • packages/db/src/services/insights/modules/devices.module.ts
  • apps/start/src/routeTree.gen.ts
  • packages/db/src/services/insights/types.ts
  • apps/worker/src/boot-workers.ts
  • packages/db/src/types.ts
apps/start/**/*.{ts,tsx}

📄 CodeRabbit inference engine (apps/start/.cursorrules)

Instrument server functions with Sentry using Sentry.startSpan() to wrap the implementation. Import Sentry with: import * as Sentry from '@sentry/tanstackstart-react'

Files:

  • apps/start/src/routes/_app.$organizationId.$projectId.index.tsx
  • apps/start/src/components/insights/insight-card.tsx
  • apps/start/src/components/overview/overview-insights.tsx
  • apps/start/src/components/sidebar-project-menu.tsx
  • apps/start/src/components/sidebar.tsx
  • apps/start/src/components/ui/carousel.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx
  • apps/start/src/utils/title.ts
  • apps/start/src/routeTree.gen.ts
🧠 Learnings (12)
📚 Learning: 2025-12-15T09:19:40.525Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: .cursorrules:0-0
Timestamp: 2025-12-15T09:19:40.525Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : When writing ClickHouse queries, always use the custom query builder from `./packages/db/src/clickhouse/query-builder.ts` and `./packages/db/src/clickhouse/query-functions.ts`

Applied to files:

  • packages/db/index.ts
  • packages/db/src/services/insights/cached-clix.ts
  • packages/db/src/clickhouse/query-builder.ts
📚 Learning: 2025-12-15T09:20:11.883Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: apps/start/.cursorrules:0-0
Timestamp: 2025-12-15T09:20:11.883Z
Learning: Applies to apps/start/routes/__root.tsx : Use `createRootRouteWithContext<T>()` to provide typed context to the router that can be accessed in loaders and other route functions

Applied to files:

  • packages/trpc/src/root.ts
📚 Learning: 2025-12-15T09:20:11.883Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: apps/start/.cursorrules:0-0
Timestamp: 2025-12-15T09:20:11.883Z
Learning: Applies to apps/start/src/router.tsx : Error collection is automatically configured in `src/router.tsx` - do not duplicate Sentry error collection configuration elsewhere

Applied to files:

  • packages/trpc/src/root.ts
📚 Learning: 2025-12-15T09:20:11.883Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: apps/start/.cursorrules:0-0
Timestamp: 2025-12-15T09:20:11.883Z
Learning: Applies to apps/start/routes/**/*.{ts,tsx} : Define routes using `createFileRoute(path)` with the path auto-managed by the bundler/CLI

Applied to files:

  • apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx
  • apps/start/src/routeTree.gen.ts
📚 Learning: 2025-12-15T09:20:11.883Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: apps/start/.cursorrules:0-0
Timestamp: 2025-12-15T09:20:11.883Z
Learning: Applies to apps/start/routes/**/*.{ts,tsx} : Un-nest routes by suffixing a segment with `_` (e.g., `posts_.$postId.edit.tsx`) to render independently from parent routes

Applied to files:

  • apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx
  • apps/start/src/routeTree.gen.ts
📚 Learning: 2025-12-15T09:20:11.883Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: apps/start/.cursorrules:0-0
Timestamp: 2025-12-15T09:20:11.883Z
Learning: Applies to apps/start/routes/**/*.{ts,tsx} : Use a trailing slash in `createFileRoute()` for index routes that target the exact parent path (e.g., `/posts/`)

Applied to files:

  • apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx
  • apps/start/src/routeTree.gen.ts
📚 Learning: 2025-12-15T09:20:11.883Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: apps/start/.cursorrules:0-0
Timestamp: 2025-12-15T09:20:11.883Z
Learning: Applies to apps/start/routes/**/*.{ts,tsx} : Use `<Outlet/>` component in layout routes to render child route components

Applied to files:

  • apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx
  • apps/start/src/routeTree.gen.ts
📚 Learning: 2025-12-15T09:20:11.883Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: apps/start/.cursorrules:0-0
Timestamp: 2025-12-15T09:20:11.883Z
Learning: Applies to apps/start/routes/**/*.{ts,tsx} : Use `(group)` folder names to organize routes for file structure only, without affecting URL paths or nesting

Applied to files:

  • apps/start/src/routeTree.gen.ts
📚 Learning: 2025-12-15T09:20:11.883Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: apps/start/.cursorrules:0-0
Timestamp: 2025-12-15T09:20:11.883Z
Learning: Applies to apps/start/routes/**/*.{ts,tsx} : Prefix files and folders with `-` to exclude them from route generation and enable safe file colocation (e.g., `-posts-table.tsx`, `-components/`)

Applied to files:

  • apps/start/src/routeTree.gen.ts
📚 Learning: 2025-12-15T09:20:11.883Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: apps/start/.cursorrules:0-0
Timestamp: 2025-12-15T09:20:11.883Z
Learning: Applies to apps/start/routes/**/*.{ts,tsx} : Use pathless layout routes by prefixing filenames with `_` (e.g., `_settingsShell`) to wrap children without matching URL segments

Applied to files:

  • apps/start/src/routeTree.gen.ts
📚 Learning: 2025-12-15T09:20:11.883Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: apps/start/.cursorrules:0-0
Timestamp: 2025-12-15T09:20:11.883Z
Learning: Applies to apps/start/routes/**/*.{ts,tsx} : Use a lone `$` segment for splat (catch-all) routes to capture the rest of the path into `params._splat`

Applied to files:

  • apps/start/src/routeTree.gen.ts
📚 Learning: 2025-12-15T09:20:11.883Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: apps/start/.cursorrules:0-0
Timestamp: 2025-12-15T09:20:11.883Z
Learning: Applies to apps/start/routes/**/*.{ts,tsx} : Use dynamic segments with `$param` syntax (e.g., `/posts/$postId`) for parameterized routes

Applied to files:

  • apps/start/src/routeTree.gen.ts
🧬 Code graph analysis (18)
apps/start/src/routes/_app.$organizationId.$projectId.index.tsx (1)
apps/start/src/components/overview/overview-insights.tsx (1)
  • OverviewInsights (18-75)
packages/trpc/src/root.ts (1)
packages/trpc/src/routers/insight.ts (1)
  • insightRouter (7-102)
packages/db/src/services/insights/windows.ts (1)
packages/db/src/services/insights/types.ts (2)
  • WindowKind (10-10)
  • WindowRange (12-19)
packages/db/src/services/insights/material.ts (1)
packages/db/src/services/insights/types.ts (2)
  • PersistedInsight (95-108)
  • MaterialDecision (119-123)
apps/worker/src/jobs/cron.ts (1)
apps/worker/src/jobs/insights.ts (1)
  • insightsDailyJob (29-45)
packages/db/src/services/insights/modules/entry-pages.module.ts (3)
packages/db/src/services/insights/types.ts (4)
  • ComputeContext (21-33)
  • InsightModule (71-92)
  • ComputeResult (35-43)
  • RenderedCard (56-61)
packages/db/src/clickhouse/client.ts (2)
  • TABLE_NAMES (50-62)
  • formatClickhouseDate (281-292)
packages/db/src/services/insights/utils.ts (7)
  • getEndOfDay (92-96)
  • buildLookupMap (107-119)
  • getWeekday (8-10)
  • computeWeekdayMedians (32-58)
  • selectTopDimensions (130-151)
  • computeChangePct (63-72)
  • computeDirection (77-86)
packages/db/src/services/insights/cached-clix.ts (1)
packages/db/src/clickhouse/query-builder.ts (1)
  • Query (45-609)
packages/trpc/src/routers/insight.ts (3)
packages/trpc/src/trpc.ts (2)
  • createTRPCRouter (134-134)
  • protectedProcedure (166-170)
packages/trpc/src/errors.ts (1)
  • TRPCAccessError (3-7)
packages/db/src/prisma-client.ts (1)
  • db (292-292)
packages/db/src/services/insights/modules/referrers.module.ts (3)
packages/db/src/services/insights/types.ts (4)
  • ComputeContext (21-33)
  • InsightModule (71-92)
  • ComputeResult (35-43)
  • RenderedCard (56-61)
packages/db/src/clickhouse/client.ts (2)
  • TABLE_NAMES (50-62)
  • formatClickhouseDate (281-292)
packages/db/src/services/insights/utils.ts (7)
  • getEndOfDay (92-96)
  • buildLookupMap (107-119)
  • getWeekday (8-10)
  • computeWeekdayMedians (32-58)
  • selectTopDimensions (130-151)
  • computeChangePct (63-72)
  • computeDirection (77-86)
packages/db/src/services/insights/scoring.ts (1)
packages/db/src/services/insights/types.ts (1)
  • ComputeResult (35-43)
packages/db/src/services/insights/utils.ts (1)
packages/db/src/clickhouse/query-builder.ts (1)
  • end (643-655)
apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx (6)
packages/common/src/math.ts (1)
  • sum (20-21)
apps/start/src/components/ui/carousel.tsx (5)
  • Carousel (259-259)
  • CarouselContent (260-260)
  • CarouselItem (261-261)
  • CarouselPrevious (262-262)
  • CarouselNext (263-263)
apps/start/src/components/ui/table.tsx (1)
  • TableButtons (5-17)
apps/public/src/components/ui/input.tsx (1)
  • Input (39-39)
apps/start/src/components/ui/select.tsx (5)
  • Select (173-173)
  • SelectTrigger (181-181)
  • SelectValue (182-182)
  • SelectContent (174-174)
  • SelectItem (176-176)
apps/start/src/components/insights/insight-card.tsx (1)
  • InsightCard (29-199)
apps/worker/src/index.ts (1)
packages/queue/src/queues.ts (1)
  • insightsQueue (248-256)
packages/queue/src/queues.ts (1)
packages/redis/redis.ts (1)
  • getRedisQueue (93-105)
packages/db/src/services/insights/modules/devices.module.ts (2)
packages/db/src/clickhouse/client.ts (2)
  • TABLE_NAMES (50-62)
  • formatClickhouseDate (281-292)
packages/db/src/services/insights/utils.ts (7)
  • getEndOfDay (92-96)
  • buildLookupMap (107-119)
  • getWeekday (8-10)
  • computeMedian (15-21)
  • selectTopDimensions (130-151)
  • computeChangePct (63-72)
  • computeDirection (77-86)
packages/db/src/services/insights/types.ts (1)
packages/validation/src/types.insights.ts (1)
  • InsightPayload (33-43)
apps/worker/src/boot-workers.ts (2)
packages/queue/src/queues.ts (1)
  • insightsQueue (248-256)
apps/worker/src/jobs/insights.ts (1)
  • insightsProjectJob (47-72)
packages/db/src/types.ts (1)
packages/validation/src/types.insights.ts (1)
  • InsightPayload (33-43)
🔇 Additional comments (44)
apps/start/src/components/sidebar.tsx (1)

126-126: LGTM! Clean UI improvement.

The hide-scrollbar class addition creates a cleaner visual appearance while maintaining scrolling functionality via overflow-auto. Keyboard navigation and mouse/touch scrolling remain fully functional.

sh/docker-build (1)

49-57: LGTM! Prerelease tagging logic is correct.

The prerelease tag format $full_version-$PRERELEASE correctly produces tags like "lindesvard/openpanel-app:1.0.0-beta". The implementation properly appends the prerelease identifier to the version tag.

packages/db/code-migrations/4-add-sessions.ts (1)

103-105: Critical: Use the custom ClickHouse query builder instead of raw SQL strings.

The coding guidelines require all ClickHouse queries to use the custom query builder from ./packages/db/src/clickhouse/query-builder.ts and ./packages/db/src/clickhouse/query-functions.ts. This code bypasses that requirement by constructing a raw SQL string and calling chMigrationClient.query() directly.

Refactor the query construction to use the custom query builder to ensure consistency and maintainability across the codebase.

While the epoch guard logic itself is sound (the startsWith('1970') check is valid since ClickHouse returns DateTime values in YYYY-MM-DD hh:mm:ss format by default), addressing the query builder requirement is mandatory.

⛔ Skipped due to learnings
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: .cursorrules:0-0
Timestamp: 2025-12-15T09:19:40.525Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : When writing ClickHouse queries, always use the custom query builder from `./packages/db/src/clickhouse/query-builder.ts` and `./packages/db/src/clickhouse/query-functions.ts`
packages/db/src/clickhouse/query-builder.ts (1)

83-98: LGTM!

The method signature and implementation correctly handle both string and Expression types. The type guards properly filter out falsy values while preserving both strings and Expression objects.

apps/start/src/components/sidebar-project-menu.tsx (1)

20-20: LGTM! Clean navigation integration.

The Insights link is properly integrated into the sidebar with appropriate icon and routing. The section header change from "Insights" to "Analytics" provides clearer distinction between the analytics section and the new Insights feature.

Also applies to: 43-43, 50-54

apps/worker/src/jobs/cron.ts (1)

9-9: LGTM! Consistent cron job integration.

The new insightsDaily case handler follows the established pattern and correctly delegates to the insightsDailyJob function.

Also applies to: 31-33

packages/constants/index.ts (1)

249-499: LGTM! Comprehensive country mapping.

The countries object provides a thorough mapping of ISO country codes to full names, supporting internationalization needs for the geo insights module.

packages/db/prisma/schema.prisma (2)

501-505: LGTM! Well-defined insight state enum.

The InsightState enum provides clear states for insight lifecycle management (active, suppressed, closed).


543-554: LGTM! Proper audit trail for insights.

The InsightEvent model provides good event tracking with appropriate cascade behavior and indexing for chronological queries.

packages/validation/src/types.insights.ts (1)

1-43: LGTM! Well-structured insight types with good compatibility design.

The type definitions are comprehensive and well-thought-out:

  • Clear metric types and units
  • Flexible InsightExtra with index signature for module-specific data
  • kind field in InsightPayload enables versioning for backwards compatibility
  • Appropriate use of Partial<Record<...>> for optional metrics

The comment noting that this payload is "embedded in DB rows and shipped to the frontend, so it must remain backwards compatible" shows good architectural awareness.

apps/start/src/components/ui/carousel.tsx (1)

211-211: LGTM! Minor whitespace cleanup.

Removed unnecessary space in className string. No functional impact.

apps/worker/src/index.ts (1)

9-9: LGTM! Proper queue monitoring integration.

The insightsQueue is correctly wired into the Bull Board UI for monitoring, following the established pattern for other queues.

Also applies to: 46-46

packages/validation/index.ts (1)

3-3: LGTM!

The export follows the existing pattern and properly exposes the insights types from the validation package.

packages/db/index.ts (1)

31-31: LGTM!

The export follows the established pattern for service exports and properly exposes the insights service from the db package.

packages/db/prisma/migrations/20251217210920_insights/migration.sql (1)

1-13: Verify that the column removals are safe for production.

This migration drops three columns (changePct, compareValue, currentValue) from the project_insights table, which will result in data loss if these columns contain data. Ensure that:

  1. These columns are no longer needed in the new insights implementation
  2. Any existing data has been migrated or is no longer required
  3. This change is coordinated with the application deployment to avoid runtime errors
packages/db/src/types.ts (2)

6-6: LGTM!

The import properly brings in the InsightPayload type from the validation package.


22-22: LGTM!

The type alias correctly maps the InsightPayload to the Prisma JSON type namespace, following the established pattern for other JSON types in this file.

apps/start/src/utils/title.ts (1)

93-93: LGTM!

The INSIGHTS page title constant is added appropriately and follows the existing pattern.

apps/worker/src/boot-cron.ts (1)

37-41: LGTM! Consider documenting the timezone.

The insights daily cron job is properly configured following the existing pattern. The schedule '0 2 * * *' will run at 2 AM daily, which is appropriate for background processing during low-traffic hours.

Note that cron patterns use the server's timezone. If timezone-specific scheduling is important, consider documenting the expected timezone or using a library that supports explicit timezone configuration.

packages/trpc/src/root.ts (1)

8-8: LGTM! Clean router integration.

The insight router is properly imported and registered following the established pattern used by all other routers in the application.

Also applies to: 51-51

packages/validation/src/index.ts (1)

557-557: LGTM! Consistent barrel export.

The re-export follows the established pattern in this file and properly exposes insights validation types through the package's public API.

packages/db/src/services/insights/index.ts (1)

1-8: LGTM! Clean barrel export pattern.

This index file properly consolidates all insights service exports into a single entry point, following best practices for module organization.

apps/start/src/routes/_app.$organizationId.$projectId.index.tsx (1)

6-6: LGTM! Proper component integration.

The OverviewInsights component is cleanly integrated into the dashboard grid, with the projectId correctly passed from route parameters.

Also applies to: 54-54

packages/db/src/services/insights/modules/index.ts (1)

1-5: LGTM! Clean module exports.

The barrel export properly consolidates all insight module exports, providing a clean import surface for consumers.

apps/start/src/components/insights/insight-card.tsx (1)

11-21: LGTM! Safe null handling and rendering logic.

The helper functions (formatWindowKind, formatValue, renderTitle) and DeltaChip component use proper null handling with optional chaining and fallback values. The conditional rendering logic is well-structured.

Also applies to: 59-96, 201-229

packages/db/src/services/insights/scoring.ts (1)

3-18: LGTM! Robust scoring implementation.

The scoring functions use safe mathematical operations with proper null handling. The log1p transformation and severity thresholds provide a reasonable heuristic for prioritizing insights by impact.

apps/worker/src/boot-workers.ts (1)

10-10: LGTM! Consistent worker integration.

The insights worker is properly integrated following the established pattern used by all other workers. The default concurrency of 5 is reasonable for insights processing, and the worker includes proper lifecycle event handling.

Also applies to: 25-25, 54-62, 200-209

apps/worker/src/jobs/insights.ts (1)

47-72: LGTM!

The insightsProjectJob implementation correctly extracts payload data, builds the engine with appropriate modules, and runs the analysis. The idempotent job IDs in the daily job ensure safe re-runs.

apps/start/src/components/overview/overview-insights.tsx (2)

28-46: LGTM - loading state implementation.

The skeleton carousel provides good UX during loading with appropriate responsive breakpoints matching the main carousel.


61-65: setFilter is synchronous, not asynchronous.

The setFilter function from useEventQueryFilters is a synchronous state setter created with useCallback. It does not return a Promise, so the concern about unhandled promises in the forEach loop is invalid. The current pattern is correct for iterating over dimensions and applying synchronous filter updates.

Likely an incorrect or invalid review comment.

packages/db/src/services/insights/modules/devices.module.ts (1)

225-274: LGTM - render implementation.

The render function correctly constructs the insight card with appropriate metrics, direction indicators, and payload structure for the insight_v1 format.

packages/db/src/services/insights/cached-clix.ts (1)

1-68: LGTM! Clean caching wrapper implementation.

The caching mechanism correctly:

  • Uses SHA-256 hash of SQL + timezone as cache key to ensure query uniqueness
  • Preserves the original clix API by copying static methods
  • Handles optional cache gracefully
  • Uses the custom query builder as per coding guidelines
packages/db/src/services/insights/modules/page-trends.module.ts (2)

233-297: LGTM!

The render function correctly:

  • Extracts absolute pageviews from extra since currentValue/compareValue are now share-based
  • Handles the isNew case with appropriate title formatting
  • Constructs a well-structured payload with both pageviews and share metrics

173-185: No actionable issues found. The code correctly uses a shared cache context via createCachedClix, which ensures identical queries within enumerateDimensions and computeMany are deduplicated. Both methods receive the same ctx object with the same cached clix instance, guaranteeing cache hits for duplicate queries.

packages/trpc/src/routers/insight.ts (1)

72-101: LGTM!

The listAll procedure provides a straightforward way to fetch all active insights without the deduplication logic, which is useful for debugging or administrative views.

packages/db/src/services/insights/modules/referrers.module.ts (1)

159-211: LGTM!

The referrers module correctly:

  • Implements the InsightModule interface with appropriate thresholds
  • Tracks both isNew and isGone referrers in the extra field
  • Uses the cached clix query builder per coding guidelines
  • Follows the established pattern from other modules
packages/db/src/services/insights/modules/entry-pages.module.ts (1)

174-287: LGTM!

The entry pages module correctly implements the InsightModule interface with:

  • Appropriate thresholds for entry page analysis
  • Proper handling of origin/path dimensions
  • Well-structured render output with sessions and share metrics

Note: Unlike the referrers module, this doesn't track isGone entries. This may be intentional since entry pages appearing/disappearing might be less actionable than referrer changes.

packages/db/src/services/insights/utils.ts (2)

15-21: LGTM - computeMedian handles edge cases correctly.

The function properly handles empty arrays and correctly computes median for both odd and even-length arrays. The null-coalescing fallbacks provide safe defaults.


130-151: LGTM - selectTopDimensions correctly implements union ranking.

The function properly merges dimensions from both maps and ranks by the maximum value across current/baseline periods, ensuring dimensions significant in either period are included.

packages/db/src/services/insights/store.ts (1)

100-111: LGTM - Insight lookup and reopen logic is well-structured.

The upsert logic correctly handles three cases: updating existing active insights, reopening closed/suppressed insights, and creating new ones. ThreadId preservation ensures continuity.

packages/db/src/services/insights/modules/geo.module.ts (1)

165-177: No action needed—duplicate queries are already handled by the caching mechanism.

Both enumerateDimensions and computeMany receive the same ComputeContext with a shared cache Map. When fetchGeoAggregates executes its queries via ctx.clix(), the first call caches results by query hash, and the second call reuses them. No duplicate queries reach ClickHouse within a module+window execution.

apps/start/src/routeTree.gen.ts (1)

1-10: Auto-generated file — no manual review required.

This file is auto-generated by TanStack Router and the header explicitly states it should not be modified. The new /insights route wiring is correctly generated and follows the existing patterns.

packages/db/src/services/insights/types.ts (2)

71-92: Well-designed module interface with clear responsibilities.

The InsightModule interface cleanly separates concerns:

  • enumerateDimensions / computeMany for data fetching
  • render for presentation (with explicit "no DB reads" constraint)
  • score for ranking
  • drivers for AI/explain features

The optional hooks and per-module threshold overrides provide good flexibility.


129-191: InsightStore abstraction provides clean persistence layer.

The interface properly abstracts:

  • Project enumeration for batch processing
  • Identity-based lookups with composite key
  • Upsert with material change tracking
  • Event logging for audit trail
  • Lifecycle management (close stale, suppress low-impact)

This enables easy testing with mock implementations and keeps the engine database-agnostic.

Comment on lines +36 to +43
const availableMetrics = Object.entries(payload?.metrics ?? {});

// Pick what to display: prefer share if available (geo/devices), else primaryMetric
const [metricIndex, setMetricIndex] = useState(
availableMetrics.findIndex(([key]) => key === payload?.primaryMetric),
);
const currentMetricKey = availableMetrics[metricIndex][0];
const currentMetricEntry = availableMetrics[metricIndex][1];
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Add bounds checking for metrics array access.

The code doesn't handle the case where availableMetrics is empty or when findIndex returns -1. This will cause runtime errors when accessing availableMetrics[metricIndex] if no metrics are available or the primary metric is not found.

🔎 Apply this diff to add proper bounds checking:
  const availableMetrics = Object.entries(payload?.metrics ?? {});

+ // Guard against empty metrics or missing primary metric
+ if (availableMetrics.length === 0) {
+   return null; // or render a fallback UI
+ }
+
  // Pick what to display: prefer share if available (geo/devices), else primaryMetric
  const [metricIndex, setMetricIndex] = useState(
    availableMetrics.findIndex(([key]) => key === payload?.primaryMetric),
  );
+ 
+ // Fallback to first metric if primary not found
+ const safeMetricIndex = metricIndex === -1 ? 0 : metricIndex;
- const currentMetricKey = availableMetrics[metricIndex][0];
- const currentMetricEntry = availableMetrics[metricIndex][1];
+ const currentMetricKey = availableMetrics[safeMetricIndex][0];
+ const currentMetricEntry = availableMetrics[safeMetricIndex][1];
📝 Committable suggestion

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

Suggested change
const availableMetrics = Object.entries(payload?.metrics ?? {});
// Pick what to display: prefer share if available (geo/devices), else primaryMetric
const [metricIndex, setMetricIndex] = useState(
availableMetrics.findIndex(([key]) => key === payload?.primaryMetric),
);
const currentMetricKey = availableMetrics[metricIndex][0];
const currentMetricEntry = availableMetrics[metricIndex][1];
const availableMetrics = Object.entries(payload?.metrics ?? {});
// Guard against empty metrics or missing primary metric
if (availableMetrics.length === 0) {
return null; // or render a fallback UI
}
// Pick what to display: prefer share if available (geo/devices), else primaryMetric
const [metricIndex, setMetricIndex] = useState(
availableMetrics.findIndex(([key]) => key === payload?.primaryMetric),
);
// Fallback to first metric if primary not found
const safeMetricIndex = metricIndex === -1 ? 0 : metricIndex;
const currentMetricKey = availableMetrics[safeMetricIndex][0];
const currentMetricEntry = availableMetrics[safeMetricIndex][1];
🤖 Prompt for AI Agents
In apps/start/src/components/insights/insight-card.tsx around lines 36 to 43,
the code assumes availableMetrics has items and that findIndex finds the primary
metric — add bounds checking so we never index out of range: initialize
metricIndex to 0 when availableMetrics is empty or when findIndex returns -1 (or
set state to -1 and handle that case), and when computing
currentMetricKey/currentMetricEntry only access availableMetrics[metricIndex]
after confirming metricIndex is inside [0, availableMetrics.length-1]; otherwise
set currentMetricKey/currentMetricEntry to undefined (or a safe fallback) so
rendering logic can handle the "no metrics" case.

Comment on lines +135 to +150
{availableMetrics.length > 1 ? (
<button
type="button"
className="text-[11px] text-muted-foreground capitalize flex items-center gap-1"
onClick={() =>
setMetricIndex((metricIndex + 1) % availableMetrics.length)
}
>
<RotateCcwIcon className="size-2" />
Show{' '}
{metricKeyToLabel(
availableMetrics[
(metricIndex + 1) % availableMetrics.length
][0],
)}
</button>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Division by zero risk in metric rotation.

The metric rotation logic uses % availableMetrics.length which would result in NaN if the array is empty. This is already addressed by the guard suggested in the previous comment, but ensure the fix is applied consistently.

🤖 Prompt for AI Agents
In apps/start/src/components/insights/insight-card.tsx around lines 135 to 150,
the metric rotation uses (metricIndex + 1) % availableMetrics.length which can
divide by zero if the array is empty; ensure you guard the calculation by
checking availableMetrics.length > 0 before computing the new index and only
call setMetricIndex when length > 0 (or length > 1 for rotation), and keep the
existing render guard consistent by disabling or hiding the button when
availableMetrics.length <= 1 so the modulo is never executed with zero.

Comment on lines +1 to +9
/*
Warnings:
- Made the column `payload` on table `project_insights` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "public"."project_insights" ALTER COLUMN "payload" SET NOT NULL,
ALTER COLUMN "payload" SET DEFAULT '{}';
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

This migration will fail on any table containing NULL payload values.

The migration attempts to set a NOT NULL constraint without first backfilling existing NULL rows. While it includes SET DEFAULT '{}', this only applies to future inserts—not existing rows. SET NOT NULL may only be applied to a column provided none of the records in the table contain a NULL value for the column.

Add an UPDATE statement before the ALTER TABLE to backfill NULL values:

UPDATE "public"."project_insights" SET "payload" = '{}' WHERE "payload" IS NULL;

Then apply the ALTER TABLE statement.

🤖 Prompt for AI Agents
In
packages/db/prisma/migrations/20251217204808_insight_payload_default/migration.sql
around lines 1 to 9, the migration sets payload to NOT NULL but does not
backfill existing NULL values, causing the ALTER TABLE to fail; add a preceding
UPDATE that sets payload = '{}' for rows where payload IS NULL, then run the
ALTER TABLE to set DEFAULT '{}' and SET NOT NULL so existing NULLs are fixed
before the constraint is applied.

Comment on lines +377 to +384
this._select
// Important: Expressions are treated as raw SQL; do not run escapeDate()
// on them, otherwise any embedded date strings get double-escaped
// (e.g. ''2025-12-16 23:59:59'') which ClickHouse rejects.
.map((col) =>
col instanceof Expression ? col.toString() : this.escapeDate(col),
)
.join(', '),
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all usages of clix.exp() with context
rg -nP --type=ts --type=tsx --type=js --type=jsx -C3 'clix\.exp\s*\(' -g '!**/node_modules/**' -g '!**/dist/**' -g '!**/build/**'

Repository: Openpanel-dev/openpanel

Length of output: 94


🏁 Script executed:

#!/bin/bash
# Find all usages of clix.exp() with context
rg -nP --type=ts --type=js -C3 'clix\.exp\s*\('

Repository: Openpanel-dev/openpanel

Length of output: 19171


🏁 Script executed:

#!/bin/bash
# Read conversion.service.ts around line 98 to understand the group variable context
sed -n '80,120p' packages/db/src/services/conversion.service.ts

Repository: Openpanel-dev/openpanel

Length of output: 1554


🏁 Script executed:

#!/bin/bash
# Find the function signature and beginning context for this code
sed -n '1,100p' packages/db/src/services/conversion.service.ts | head -80

Repository: Openpanel-dev/openpanel

Length of output: 2523


🏁 Script executed:

#!/bin/bash
# Read event.service.ts around line 864 to see the nested exp() calls
sed -n '850,880p' packages/db/src/services/event.service.ts

Repository: Openpanel-dev/openpanel

Length of output: 991


🏁 Script executed:

#!/bin/bash
# Find and read the formatClickhouseDate function
rg -n "formatClickhouseDate" packages/db/src -A 5 -B 2 | head -50

Repository: Openpanel-dev/openpanel

Length of output: 4598


🏁 Script executed:

#!/bin/bash
# Find the formatClickhouseDate function definition
rg -n "formatClickhouseDate\s*=" packages/db/src/clickhouse/client.ts -A 3

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Search for formatClickhouseDate function definition in clickhouse/client.ts
rg -n "formatClickhouseDate" packages/db/src/clickhouse/client.ts -B 2 -A 5

Repository: Openpanel-dev/openpanel

Length of output: 268


🏁 Script executed:

#!/bin/bash
# Read the complete formatClickhouseDate function
sed -n '281,295p' packages/db/src/clickhouse/client.ts

Repository: Openpanel-dev/openpanel

Length of output: 472


🏁 Script executed:

#!/bin/bash
# Check what getSelectPropertyKey does with the breakdown names
rg -n "getSelectPropertyKey" packages/db/src/services/chart.service.ts -B 2 -A 8 | head -40

Repository: Openpanel-dev/openpanel

Length of output: 1466


🏁 Script executed:

#!/bin/bash
# Check how breakdowns flow into the query - trace back to IChartInput
rg -n "type IChartInput" packages -A 20 | head -50

Repository: Openpanel-dev/openpanel

Length of output: 2108


🏁 Script executed:

#!/bin/bash
# Find the zChartInput schema definition to see how breakdowns are validated
rg -n "const zChartInput" packages/validation/src -A 30 | head -60

Repository: Openpanel-dev/openpanel

Length of output: 4071


🏁 Script executed:

#!/bin/bash
# Find zChartBreakdowns and zChartBreakdown definitions
rg -n "const zChartBreakdown" packages/validation/src -B 2 -A 10 | head -60

Repository: Openpanel-dev/openpanel

Length of output: 1927


🏁 Script executed:

#!/bin/bash
# Check the query-builder.ts file to see the clix.exp export and understand the Expression class
grep -n "export.*exp\|class Expression" packages/db/src/clickhouse/query-builder.ts | head -20

Repository: Openpanel-dev/openpanel

Length of output: 88


🏁 Script executed:

#!/bin/bash
# Read the Expression class and clix.exp export
sed -n '37,80p' packages/db/src/clickhouse/query-builder.ts

Repository: Openpanel-dev/openpanel

Length of output: 1241


🏁 Script executed:

#!/bin/bash
# Search for clix.exp export
rg -n "clix.exp\s*=" packages/db/src/clickhouse/query-builder.ts -B 2 -A 2

Repository: Openpanel-dev/openpanel

Length of output: 251


The original concern about SQL injection via clix.exp() is valid and requires immediate attention.

While most clix.exp() usages are safe (using hardcoded functions like now(), toIntervalMonth(), or formatClickhouseDate()), there is a critical vulnerability in packages/db/src/services/conversion.service.ts around line 98.

The breakdownGroupBy array—derived from user-supplied breakdown names—is interpolated directly into the template literal passed to clix.exp():

const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
// Later in line 98-113:
clix.exp(`
(SELECT 
  a.${group},
  ...
  ${breakdownGroupBy.length ? `${breakdownGroupBy.join(', ')},` : ''}
  ...
  GROUP BY a.${group}, a.a_time, a.event_day${breakdownGroupBy.length ? `, ${breakdownGroupBy.join(', ')}` : ''})
`)

Although breakdownGroupBy values are aliased (e.g., b_0), the underlying getSelectPropertyKey(b.name) derives SQL expressions from user input without escaping, creating an injection vector. Validate and sanitize all user-controlled input before passing to clix.exp(), or refactor to use parameterized queries instead of template interpolation.

🤖 Prompt for AI Agents
In packages/db/src/clickhouse/query-builder.ts around lines 377 to 384, the
current code treats Expression inputs as raw SQL and the review points out a SQL
injection risk where user-derived breakdown expressions are interpolated into
clix.exp() templates; fix this by ensuring any value coming from
getSelectPropertyKey(b.name) is validated and escaped as an identifier (or
mapped only from a whitelist of allowed column/expression keys) before being
included in template literals, avoid raw string interpolation of user-controlled
expressions by constructing clix Expression objects or using
parameterized/escaped identifier helpers provided by the query builder, and keep
the aliased names (b_0, b_1...) derived only from safe, internal logic so the
final template only inserts validated/escaped identifiers or prebuilt Expression
instances.

Comment on lines +56 to +68
.select<{ cur_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`,
),
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.execute(),
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for formatClickhouseDate implementation
rg -n "formatClickhouseDate" packages/db/src -A 5 -B 2 --type ts --type tsx

Repository: Openpanel-dev/openpanel

Length of output: 94


🏁 Script executed:

#!/bin/bash
# Check the actual code at lines 56-68 in devices.module.ts
cat -n packages/db/src/services/insights/modules/devices.module.ts | sed -n '50,75p'

echo "---"

# Check if query builder is used in this file
rg -n "query-builder\|query-functions" packages/db/src/services/insights/modules/devices.module.ts -B 2 -A 2

Repository: Openpanel-dev/openpanel

Length of output: 1157


🏁 Script executed:

#!/bin/bash
# Search for formatClickhouseDate with correct file type
rg -n "formatClickhouseDate" packages/db/src -A 5 -B 2 -t ts

echo "---"

# Check for the function definition
rg -n "export.*formatClickhouseDate\|function formatClickhouseDate" packages/db/src -A 10 -t ts

Repository: Openpanel-dev/openpanel

Length of output: 28963


🏁 Script executed:

#!/bin/bash
# Check imports in the devices.module.ts file
head -30 packages/db/src/services/insights/modules/devices.module.ts

Repository: Openpanel-dev/openpanel

Length of output: 903


🏁 Script executed:

#!/bin/bash
# Get the full formatClickhouseDate implementation
sed -n '281,295p' packages/db/src/clickhouse/client.ts

Repository: Openpanel-dev/openpanel

Length of output: 472


🏁 Script executed:

#!/bin/bash
# Check query-functions.ts to see if there's a proper way to build this expression
head -80 packages/db/src/clickhouse/query-functions.ts

Repository: Openpanel-dev/openpanel

Length of output: 171


🏁 Script executed:

#!/bin/bash
# Check what's in the clickhouse directory
ls -la packages/db/src/clickhouse/

echo "---"

# Check query-builder.ts
head -100 packages/db/src/clickhouse/query-builder.ts

Repository: Openpanel-dev/openpanel

Length of output: 2876


🏁 Script executed:

#!/bin/bash
# Search for the exp method in query-builder.ts
rg -n "exp\(" packages/db/src/clickhouse/query-builder.ts -A 5 -B 2

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Search for exp method definition
rg -n "\.exp" packages/db/src/clickhouse/query-builder.ts -A 3 -B 2

echo "---"

# Check if exp is part of the clix object
rg -n "exp" packages/db/src/clickhouse/query-builder.ts

Repository: Openpanel-dev/openpanel

Length of output: 783


🏁 Script executed:

#!/bin/bash
# Check the clix.date function and other utility functions
sed -n '663,700p' packages/db/src/clickhouse/query-builder.ts

echo "---"

# Check if there are more utility functions
rg -n "^clix\." packages/db/src/clickhouse/query-builder.ts

Repository: Openpanel-dev/openpanel

Length of output: 1720


Use query builder methods instead of raw SQL expressions.

Replace ctx.clix.exp() with template literals using clix.datetime() or similar utilities from the query builder. The code should construct queries through the query builder's designed interface rather than interpolating raw SQL strings, as established in the coding guidelines.

🤖 Prompt for AI Agents
In packages/db/src/services/insights/modules/devices.module.ts around lines 56
to 68, replace the raw SQL expression built with ctx.clix.exp(...) and
string-interpolated dates with equivalent query-builder calls: build the BETWEEN
date values using the clix datetime helper (e.g., ctx.clix.datetime or the
appropriate clix date helper) for the start and end, and construct the
countIf()/function call via the query-builder's function helpers (e.g.,
clix.func or clix.countIf) or by combining clix identifiers/columns and the clix
BETWEEN helper, passing the datetime values as parameters rather than embedding
them in a string; ensure the final select uses the query-builder expression
object so no raw SQL strings are interpolated.

Comment on lines +178 to +190
async enumerateDimensions(ctx) {
const { currentMap, baselineMap } = await fetchDeviceAggregates(ctx);
const topDims = selectTopDimensions(
currentMap,
baselineMap,
this.thresholds?.maxDims ?? 5,
);
return topDims.map((dim) => `device:${dim}`);
},

async computeMany(ctx, dimensionKeys): Promise<ComputeResult[]> {
const { currentMap, baselineMap, totalCurrent, totalBaseline } =
await fetchDeviceAggregates(ctx);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Double fetch of device aggregates degrades performance.

Both enumerateDimensions and computeMany call fetchDeviceAggregates, resulting in duplicate ClickHouse queries. Consider caching the result in the context or restructuring to fetch once.

🔎 Suggested approach

One pattern is to have enumerateDimensions store the aggregates in a shared context or return them alongside dimension keys, so computeMany can reuse them:

// Option 1: Cache in module-level or context
let cachedAggregates: Awaited<ReturnType<typeof fetchDeviceAggregates>> | null = null;

async function getDeviceAggregates(ctx: ComputeContext) {
  if (!cachedAggregates) {
    cachedAggregates = await fetchDeviceAggregates(ctx);
  }
  return cachedAggregates;
}

// Option 2: Pass data through engine workflow if supported

This would cut the number of ClickHouse round-trips in half for this module.

🤖 Prompt for AI Agents
In packages/db/src/services/insights/modules/devices.module.ts around lines 178
to 190, both enumerateDimensions and computeMany independently call
fetchDeviceAggregates causing duplicate ClickHouse queries; modify the module so
fetchDeviceAggregates is executed once and its result reused — either cache the
returned aggregates on the provided ctx (e.g., ctx.deviceAggregates) the first
time it’s called and have both methods read from that cache, or change
enumerateDimensions to return the aggregates along with dimension keys and have
computeMany accept/use those aggregates; ensure the cached value includes
currentMap, baselineMap, totalCurrent and totalBaseline and that the cache is
invalidated or scoped appropriately to avoid cross-request leakage.

Comment on lines +61 to +72
ctx.clix.exp(
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`,
),
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.execute(),
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find formatClickhouseDate implementation
rg -n "formatClickhouseDate" --type ts -B 2 -A 5 packages/db/src

# Also check where it's imported from
rg -n "import.*formatClickhouseDate" --type ts packages/db/src

Repository: Openpanel-dev/openpanel

Length of output: 30006


🏁 Script executed:

#!/bin/bash
# Get context of geo.module.ts around lines 61-72
cat -n packages/db/src/services/insights/modules/geo.module.ts | sed -n '50,80p'

Repository: Openpanel-dev/openpanel

Length of output: 1266


🏁 Script executed:

#!/bin/bash
# Check what ctx.clix is and if it aligns with coding guidelines
rg -n "ctx\.clix" --type ts packages/db/src/services/insights/modules/geo.module.ts -B 2 -A 2 | head -30

# Also check the query builder imports in this file
head -30 packages/db/src/services/insights/modules/geo.module.ts

Repository: Openpanel-dev/openpanel

Length of output: 1823


🏁 Script executed:

#!/bin/bash
# Get complete formatClickhouseDate implementation
sed -n '281,295p' packages/db/src/clickhouse/client.ts

Repository: Openpanel-dev/openpanel

Length of output: 472


🏁 Script executed:

#!/bin/bash
# Check the query-builder to understand clix and exp() method
head -100 packages/db/src/clickhouse/query-builder.ts | cat -n

Repository: Openpanel-dev/openpanel

Length of output: 3211


🏁 Script executed:

#!/bin/bash
# Search for the exp method definition in query builder
rg -n "exp\(" packages/db/src/clickhouse/query-builder.ts -B 2 -A 5

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Search for exp method more broadly
rg -n "\.exp" packages/db/src/clickhouse/query-builder.ts -B 2 -A 3

Repository: Openpanel-dev/openpanel

Length of output: 388


🏁 Script executed:

#!/bin/bash
# Look for where clix is defined and exported
rg -n "export.*clix\|clix\s*=" packages/db/src/clickhouse/query-builder.ts -B 2 -A 3 | head -50

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Check the query-functions.ts file to see what functions are available
head -50 packages/db/src/clickhouse/query-functions.ts

Repository: Openpanel-dev/openpanel

Length of output: 171


🏁 Script executed:

#!/bin/bash
# Verify that clix is exported from query-builder
rg -n "export.*clix|^const clix|^export const clix" packages/db/src/clickhouse/query-builder.ts

Repository: Openpanel-dev/openpanel

Length of output: 145


🏁 Script executed:

#!/bin/bash
# Check what files exist in the clickhouse directory
ls -la packages/db/src/clickhouse/

Repository: Openpanel-dev/openpanel

Length of output: 427


🏁 Script executed:

#!/bin/bash
# Search for query-functions references in the codebase
rg -r "query-functions" packages/db/src/

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Check if there are any parameterized query examples or safer patterns used elsewhere
rg -n "\.where.*BETWEEN.*\[" packages/db/src/services/insights/modules/geo.module.ts -B 2 -A 2

Repository: Openpanel-dev/openpanel

Length of output: 1128


🏁 Script executed:

#!/bin/bash
# Check how the .where() method handles the BETWEEN operator with parameters
rg -n "where.*:" packages/db/src/clickhouse/query-builder.ts -A 20 | head -80

Repository: Openpanel-dev/openpanel

Length of output: 2830


🏁 Script executed:

#!/bin/bash
# Get the full where method implementation
sed -n '150,250p' packages/db/src/clickhouse/query-builder.ts

Repository: Openpanel-dev/openpanel

Length of output: 3034


🏁 Script executed:

#!/bin/bash
# Find the escapeValue method implementation
rg -n "escapeValue" packages/db/src/clickhouse/query-builder.ts -A 15 | head -50

Repository: Openpanel-dev/openpanel

Length of output: 1797


🏁 Script executed:

#!/bin/bash
# Find the escapeDate method implementation
rg -n "escapeDate" packages/db/src/clickhouse/query-builder.ts -A 10

Repository: Openpanel-dev/openpanel

Length of output: 2184


🏁 Script executed:

#!/bin/bash
# Check what clix.datetime returns
rg -n "clix\.datetime\|datetime.*=" packages/db/src/clickhouse/query-builder.ts -A 3

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Look at all uses of ctx.clix.exp in geo.module.ts to see the full pattern
cat -n packages/db/src/services/insights/modules/geo.module.ts | sed -n '60,72p;106,111p;125,130p'

Repository: Openpanel-dev/openpanel

Length of output: 1215


🏁 Script executed:

#!/bin/bash
# Verify the full context - check what ctx is and confirm it's using clix properly
rg -n "ctx\.clix\|ctx:" packages/db/src/services/insights/modules/geo.module.ts | head -20

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Check if there's a different approach used in other files - look for safer patterns
rg -n "\.select.*countIf" packages/db/src/services/insights/modules/ -A 2 | head -40

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Get broader context around the geo.module.ts code
sed -n '1,30p' packages/db/src/services/insights/modules/geo.module.ts

Repository: Openpanel-dev/openpanel

Length of output: 867


🏁 Script executed:

#!/bin/bash
# Check what ctx type is
rg -n "ComputeContext\|ctx:" packages/db/src/services/insights/modules/geo.module.ts | head -10

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Check if there's any guidance in the types file
find packages/db/src/services/insights -name "types*" -o -name "*types*"

Repository: Openpanel-dev/openpanel

Length of output: 109


🏁 Script executed:

#!/bin/bash
# Check the ComputeContext type definition
rg -n "ComputeContext\|clix" packages/db/src/services/insights/types.ts -B 2 -A 5 | head -50

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Read the types file directly
cat -n packages/db/src/services/insights/types.ts | head -100

Repository: Openpanel-dev/openpanel

Length of output: 3929


🏁 Script executed:

#!/bin/bash
# Check if there are safe alternatives in the query builder for aggregate functions
rg -n "countIf\|aggregate\|expr" packages/db/src/clickhouse/query-builder.ts | head -20

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Verify the full scope of the issue - check other modules for comparison
ls -la packages/db/src/services/insights/modules/

Repository: Openpanel-dev/openpanel

Length of output: 577


🏁 Script executed:

#!/bin/bash
# Check how page-trends.module uses the same pattern to confirm consistency
diff -u <(sed -n '60,72p' packages/db/src/services/insights/modules/page-trends.module.ts) <(sed -n '60,72p' packages/db/src/services/insights/modules/geo.module.ts) || true

Repository: Openpanel-dev/openpanel

Length of output: 154


Use parameterized expressions consistently with the query builder's escapeDate method.

The raw string interpolation in ctx.clix.exp() bypasses the query builder's safe value escaping. While formatClickhouseDate produces safely formatted strings, this pattern is inconsistent with the parameterized .where() calls on the same query (lines 68–71), which properly use escapeValue(). Extract the formatted dates and pass them through the query builder's expression construction to maintain consistency with the parameterized approach used elsewhere in the builder API.

Comment on lines +12 to +25
async listProjectIdsForCadence(cadence: Cadence): Promise<string[]> {
const projects = await db.project.findMany({
where: {
deleteAt: null,
eventsCount: { gt: 10_000 },
updatedAt: { gt: new Date(Date.now() - 1000 * 60 * 60 * 24) },
organization: {
subscriptionStatus: 'active',
},
},
select: { id: true },
});
return projects.map((p) => p.id);
},
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Unused cadence parameter in listProjectIdsForCadence.

The cadence parameter is declared but never used in the query logic. Either remove it or implement cadence-specific filtering if intended.

🔎 Options

If cadence should affect the query:

async listProjectIdsForCadence(cadence: Cadence): Promise<string[]> {
  // Add cadence-specific filtering logic
  const minEvents = cadence === 'daily' ? 10_000 : 50_000;
  // ...
}

If not needed:

async listProjectIdsForCadence(_cadence: Cadence): Promise<string[]> {
  // Prefix with underscore to indicate intentionally unused
🤖 Prompt for AI Agents
packages/db/src/services/insights/store.ts lines 12-25: the cadence parameter is
declared but not used; either remove it, rename it to _cadence to signal it's
intentionally unused, or implement cadence-specific filtering (e.g., compute a
minEvents or updatedAt threshold based on cadence and apply it in the query) —
update the function signature and body accordingly and run type checks to ensure
no unused-parameter lint errors remain.

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: 0

🧹 Nitpick comments (4)
apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx (4)

52-63: Minor: replace only replaces the first hyphen occurrence.

The fallback moduleKey.replace('-', ' ') only replaces the first hyphen. If a module key ever has multiple hyphens (e.g., my-new-module), it would display as my new-module.

🔎 Consider using replaceAll for consistency
-  return displayNames[moduleKey] || moduleKey.replace('-', ' ');
+  return displayNames[moduleKey] || moduleKey.replaceAll('-', ' ');

164-199: Consider extracting duplicated severityOrder constant.

The severityOrder mapping is duplicated in both severity-desc and severity-asc switch cases. This can be extracted to avoid duplication.

🔎 Extract severityOrder outside the sort function
+const SEVERITY_ORDER: Record<string, number> = {
+  severe: 3,
+  moderate: 2,
+  low: 1,
+};
+
 const filteredAndSorted = useMemo(() => {
   // ... filtering logic ...
   
   const sorted = [...filtered].sort((a, b) => {
     switch (sortBy) {
       // ... other cases ...
       case 'severity-desc': {
-        const severityOrder: Record<string, number> = {
-          severe: 3,
-          moderate: 2,
-          low: 1,
-        };
-        const aSev = severityOrder[a.severityBand ?? ''] ?? 0;
-        const bSev = severityOrder[b.severityBand ?? ''] ?? 0;
+        const aSev = SEVERITY_ORDER[a.severityBand ?? ''] ?? 0;
+        const bSev = SEVERITY_ORDER[b.severityBand ?? ''] ?? 0;
         return bSev - aSev;
       }
       case 'severity-asc': {
-        const severityOrder: Record<string, number> = {
-          severe: 3,
-          moderate: 2,
-          low: 1,
-        };
-        const aSev = severityOrder[a.severityBand ?? ''] ?? 0;
-        const bSev = severityOrder[b.severityBand ?? ''] ?? 0;
+        const aSev = SEVERITY_ORDER[a.severityBand ?? ''] ?? 0;
+        const bSev = SEVERITY_ORDER[b.severityBand ?? ''] ?? 0;
         return aSev - bSev;
       }
       // ...
     }
   });
 }, [/* deps */]);

382-412: Remove unused index parameter.

The index parameter on line 382 is declared but never used in the map callback.

🔎 Remove unused parameter
-                    {moduleInsights.map((insight, index) => (
+                    {moduleInsights.map((insight) => (

391-410: Consider simplifying the onFilter callback creation.

The IIFE pattern works but is verbose. A simpler approach could use a conditional directly.

🔎 Simplified callback
                        <InsightCard
                          insight={insight}
-                         onFilter={(() => {
-                           const filterString = insight.payload?.dimensions
-                             .map(
-                               (dim) =>
-                                 `${dim.key},is,${encodeURIComponent(dim.value)}`,
-                             )
-                             .join(';');
-                           if (filterString) {
-                             return () => {
-                               navigate({
-                                 to: '/$organizationId/$projectId',
-                                 from: Route.fullPath,
-                                 search: {
-                                   f: filterString,
-                                 },
-                               });
-                             };
-                           }
-                           return undefined;
-                         })()}
+                         onFilter={
+                           insight.payload?.dimensions?.length
+                             ? () => {
+                                 const filterString = insight.payload.dimensions
+                                   .map(
+                                     (dim) =>
+                                       `${dim.key},is,${encodeURIComponent(dim.value)}`,
+                                   )
+                                   .join(';');
+                                 navigate({
+                                   to: '/$organizationId/$projectId',
+                                   from: Route.fullPath,
+                                   search: { f: filterString },
+                                 });
+                               }
+                             : undefined
+                         }
                        />
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5757cb2 and f00d1bd.

📒 Files selected for processing (1)
  • apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (.cursorrules)

When writing ClickHouse queries, always use the custom query builder from ./packages/db/src/clickhouse/query-builder.ts and ./packages/db/src/clickhouse/query-functions.ts

Files:

  • apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx
apps/start/**/*.{ts,tsx}

📄 CodeRabbit inference engine (apps/start/.cursorrules)

Instrument server functions with Sentry using Sentry.startSpan() to wrap the implementation. Import Sentry with: import * as Sentry from '@sentry/tanstackstart-react'

Files:

  • apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx
🧠 Learnings (4)
📚 Learning: 2025-12-15T09:20:11.883Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: apps/start/.cursorrules:0-0
Timestamp: 2025-12-15T09:20:11.883Z
Learning: Applies to apps/start/routes/**/*.{ts,tsx} : Un-nest routes by suffixing a segment with `_` (e.g., `posts_.$postId.edit.tsx`) to render independently from parent routes

Applied to files:

  • apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx
📚 Learning: 2025-12-15T09:20:11.883Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: apps/start/.cursorrules:0-0
Timestamp: 2025-12-15T09:20:11.883Z
Learning: Applies to apps/start/routes/**/*.{ts,tsx} : Define routes using `createFileRoute(path)` with the path auto-managed by the bundler/CLI

Applied to files:

  • apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx
📚 Learning: 2025-12-15T09:20:11.883Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: apps/start/.cursorrules:0-0
Timestamp: 2025-12-15T09:20:11.883Z
Learning: Applies to apps/start/routes/**/*.{ts,tsx} : Use `<Outlet/>` component in layout routes to render child route components

Applied to files:

  • apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx
📚 Learning: 2025-12-15T09:20:11.883Z
Learnt from: CR
Repo: Openpanel-dev/openpanel PR: 0
File: apps/start/.cursorrules:0-0
Timestamp: 2025-12-15T09:20:11.883Z
Learning: Applies to apps/start/routes/**/*.{ts,tsx} : Use `(group)` folder names to organize routes for file structure only, without affecting URL paths or nesting

Applied to files:

  • apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx
🧬 Code graph analysis (1)
apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx (4)
apps/start/src/utils/title.ts (2)
  • createProjectTitle (39-53)
  • PAGE_TITLES (78-129)
apps/start/src/components/page-header.tsx (1)
  • PageHeader (11-30)
apps/start/src/components/ui/table.tsx (1)
  • TableButtons (5-17)
apps/start/src/components/insights/insight-card.tsx (1)
  • InsightCard (29-199)
🔇 Additional comments (6)
apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx (6)

1-28: LGTM!

Imports are well-organized and appropriate for the component's functionality. The file is a client route component, so the Sentry instrumentation guideline for server functions doesn't apply here.


30-43: LGTM!

Route definition follows the established pattern using createFileRoute with proper path parameters. The head meta configuration correctly uses createProjectTitle for SEO.


65-74: LGTM with a note on the hardcoded limit.

The data fetching pattern using useQuery with TRPC query options is correct. The hardcoded limit: 500 works for now but consider making it configurable or implementing pagination if the insights count grows significantly.


76-112: LGTM!

Excellent use of nuqs for URL-synced filter state. This enables shareable and bookmarkable filter configurations. The parseAsStringEnum usage provides good type safety for constrained values.


212-241: LGTM!

The grouping logic correctly uses a Map for efficient aggregation and implements clear sorting priority (referrers first, then by average impact). The useMemo dependency is correct.


243-270: LGTM!

Loading state provides appropriate skeleton placeholders that mirror the final UI structure, ensuring a smooth loading experience.

@lindesvard lindesvard merged commit 5f38560 into main Dec 19, 2025
3 of 4 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.

2 participants