Skip to content

feat(dashboard): neutralize colored icons, chart theming, DB migrations#13

Merged
remcostoeten merged 9 commits into
masterfrom
feature/type-safe-ingestion-sdk-cleanup
Apr 27, 2026
Merged

feat(dashboard): neutralize colored icons, chart theming, DB migrations#13
remcostoeten merged 9 commits into
masterfrom
feature/type-safe-ingestion-sdk-cleanup

Conversation

@remcostoeten
Copy link
Copy Markdown
Owner

@remcostoeten remcostoeten commented Apr 27, 2026

Summary

  • Replace amber/sky notice banner colors with neutral border-border bg-muted/30 text-muted-foreground tokens
  • Geo map hover fill + legend gradient now use CSS vars instead of hardcoded hsl(210 100% 50%) blue
  • Add drizzle migration files for events + visitors schema (with indexes)
  • Add scripts/sync-vercel-env.sh for Vercel env sync

Test plan

  • Verify notice banners render neutral (no amber/sky)
  • Verify geo map hover is not blue
  • Confirm DATABASE_URL is set in Vercel for both apps
  • Run migrations against new Neon DB

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added analytics metrics for events, trends, notes, journal, and authentication tracking
    • Introduced dashboard filters for project scope and date range selection
    • Added search functionality to the sidebar
    • New event and visitor tracking infrastructure for detailed analytics
  • Style

    • Enhanced chart tooltips and map visualization styling
    • Improved KPI card and data table appearance
  • Chores

    • Updated build configuration and environment variable management scripts
    • Bumped analytics dependency to latest version

remcostoeten and others added 7 commits April 22, 2026 19:23
… SDK URL validation

- Register `/e` as short alias for `/ingest` endpoint; SDK now hits `/e` by default
- Detect Vercel preview hostnames (via origin or host header) and tag events with `isPreview`
- SDK no longer falls back to localhost when no ingest URL configured — drops event and logs error instead
- Expand ops script with verification menu and new unit tests
- Dashboard: refactor queries, sidebar, signal-stream, world-map, geo-details components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…it queries

Phase 1 — quick wins:
- Unify preview detection: single `isPreviewEnvironment` in geo.ts (Vercel-specific regex + staging/preview patterns), remove duplicate `isVercelPreviewHost` from ingest.ts
- Add `getHostFromOrigin` to geo.ts as canonical export
- Cache ORIGIN_ALLOWLIST with env-invalidation (lazy cache, not stale module-level const)
- Export `requireAdminAuth` from admin.ts; apply it to GET /metrics (was unauthenticated)
- Add /e alias to HTML landing route listing
- Wire per-event-type dedupe TTL via `getDedupeWindow` into `dedupeCache.add()`

Phase 2 — schema:
- Add `is_preview`, `bot_detected`, `is_internal` boolean columns to events table
- Add indexes on is_preview, bot_detected
- Migration 0002 backfills from existing JSONB meta
- Ingest handler writes flags to first-class columns instead of meta
- Bot detection queries use `bot_detected = true OR meta fallback` for backwards compat

Phase 3 — split queries.ts (851 lines) into domain modules:
- lib/queries/filters.ts — shared filter fns, PREVIEW_PATTERN, range helpers
- lib/queries/kpis.ts — KPI query functions
- lib/queries/content.ts — pages, referrers, geo, paths
- lib/queries/audience.ts — devices, browsers, OS, languages, screens
- lib/queries/sessions.ts — session stats, engagement, heatmap, retention, web vitals, UTM
- lib/queries/realtime.ts — live now, recent events, recent visitors
- lib/queries/overview.ts — trends, getDashboardData, projects, segments
- lib/queries/index.ts — barrel re-export (all imports unchanged)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- layout.tsx: remove trailing slash from fallback ingest URL (was producing //e double-slash → 404 on Hono)
- track.ts: resolve ingest URL lazily per call instead of at module load (fixes test env isolation + allows runtime env changes)
- track.test.ts: update /ingest assertions to /e, set NEXT_PUBLIC_ANALYTICS_URL in beforeEach
- signal-stream.tsx: fix hasDetails to check actual renderable fields instead of metadata key count (was always true); use !== null for numeric statusCode/duration checks (was suppressing 0 values)
- route.ts: validate timeRange against explicit whitelist, return 400 for unknown values
- client.ts: log error when DATABASE_URL missing instead of silently returning stub
- tsconfig.json: remove bun-types from Next.js app types (not a Bun runtime app); add tests/tsconfig.json that extends root and adds bun-types for test files only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…migrations

- Replace amber/sky notice banners with neutral muted tokens
- Geo map hover + legend gradient use CSS vars instead of hardcoded blue
- Add drizzle migrations for events/visitors schema with indexes
- Add sync-vercel-env script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 27, 2026

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

Project Deployment Actions Updated (UTC)
analytics-demo Ready Ready Preview, Comment Apr 27, 2026 3:50am
ingestion Ready Ready Preview, Comment Apr 27, 2026 3:50am

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Sorry @remcostoeten, your pull request is larger than the review limit of 150000 diff characters

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a new analytics data layer with database schema and query modules, extends the analytics API to support new event-based metrics, refactors dashboard components to use URL-based state management, and adds styling updates for tooltips and data visualization across multiple dashboard components.

Changes

Cohort / File(s) Summary
Analytics Query Layer
apps/example-dashboard/lib/queries/content.ts, apps/example-dashboard/lib/queries/skriuw.ts, apps/example-dashboard/lib/queries/index.ts
New query modules implementing parameterized SQL aggregations for content metrics (top pages, referrers, geo distribution, entry/exit pages, paths) and skriuw events (event counts, trends, notes/journal activity, auth metrics, recent events, top searches). Index barrel exports the new skriuw functions.
Data Model & Mock Data
apps/example-dashboard/lib/types.ts, apps/example-dashboard/lib/mock-data.ts
Extended ContentMetric with optional host field; updated mock top pages entries to include host attribution for multiple paths and docs entries.
API Route Extensions
apps/example-dashboard/app/api/analytics/route.ts
Added seven new metric branches (skriuw-events, skriuw-trend, skriuw-notes, skriuw-journal, skriuw-auth, skriuw-recent, skriuw-searches) that invoke corresponding query functions with time-range and project filtering parameters.
Dashboard State Management & Layout
apps/example-dashboard/components/dashboard-content.tsx, apps/example-dashboard/components/app-sidebar.tsx
Refactored filter/selection state from local useState to URL query parameters (projectId, timeRange, status); added project/date-range dropdown controls in sidebar; integrated geo-detail component; replaced segmentation UI with engagement metrics; updated header and notice visibility logic.
Component Styling Updates
apps/example-dashboard/app/globals.css, apps/example-dashboard/components/geo-map.tsx, apps/example-dashboard/components/trend-chart.tsx, apps/example-dashboard/components/kpi-cards.tsx
Replaced global dark scrollbar styling with Recharts tooltip styling (background, border, shadow via theme variables) and light-mode scrollbar fallback; updated geo-map hover colors, legend gradient, and tooltip styling; enhanced trend-chart tooltip appearance; added tracking-tight letter-spacing to KPI card values.
Data Table & Display
apps/example-dashboard/components/data-table.tsx
Updated row/cell styling with border and font adjustments; added new host/Domain column to TopPagesTable with truncation and em-dash fallback.
Database Schema & Migrations
apps/ingestion/src/db/migrations/0000_create_events.sql, apps/ingestion/src/db/migrations/001_add_missing_columns.sql, apps/ingestion/src/db/migrations/meta/0000_snapshot.json, apps/ingestion/src/db/migrations/meta/_journal.json
Created two tables (events and visitors) with comprehensive fields for event tracking, visitor fingerprinting, and geo/device data; added btree indexes for common query patterns; introduced migration journal and snapshot for version 7.
Configuration & Build
apps/example-dashboard/next.config.mjs, apps/example-dashboard/package.json
Enabled Next.js cache optimization (optimizePackageInputs) and explicit cache directory; updated dev script to set NEXT_CACHE_DIRECTORY; added test script; bumped @remcostoeten/analytics to ^1.4.0.
Utility Scripts
scripts/sync-vercel-env.sh
New Bash utility for syncing Vercel environment variables across two projects with check and update modes.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 Hops through dashboards with geo detail bright,
Query layers bloom where SQL takes flight,
URLs now hold the state we seek,
Events and searches, week after week!
With indices swift and migrations true,
The rabbit's prepared grand analytics for you! 🌟

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title partially describes the changes but omits major work: new analytics queries, dashboard refactoring with URL parameters, sidebar improvements, and comprehensive content metrics. Only mentions cosmetic changes (neutralized icons, chart theming) and migrations. Consider revising the title to reflect the most significant changes, such as 'feat(dashboard): add content analytics queries and URL-based filters' or include more scope like 'feat(dashboard): add analytics queries, refactor filters, neutralize UI colors'.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/type-safe-ingestion-sdk-cleanup

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

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

⚠️ Outside diff range comments (1)
apps/example-dashboard/components/world-map.tsx (1)

99-119: ⚠️ Potential issue | 🟡 Minor

Hardcoded blue HSL still present despite PR objectives.

The PR description states "Geo map hover fill and legend gradient now use CSS variables instead of a hardcoded HSL blue", but getCountryColor still returns hardcoded hsl(210, 80%, …) and hsl(210, 70%, …) blue values for hovered/default fills. The legend at line 200 also still uses hsl(210, 70%, ${l}%). Either the neutralization was missed for this file or the description overstates the scope.

🎨 Suggested neutralization using muted/foreground tokens
-		if (hoveredCountry === countryName || hoveredCountry === countryCode) {
-			return `hsl(210, 80%, ${lightness - 10}%)`;
-		}
-
-		return `hsl(210, 70%, ${lightness}%)`;
+		const alpha = 0.15 + intensity * 0.6;
+		if (hoveredCountry === countryName || hoveredCountry === countryCode) {
+			return `hsl(var(--foreground) / ${Math.min(alpha + 0.15, 1)})`;
+		}
+		return `hsl(var(--foreground) / ${alpha})`;

Apply the same swap to the legend swatches at line 200.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/components/world-map.tsx` around lines 99 - 119,
getCountryColor currently returns hardcoded blue HSL strings for the
selected/hovered/default fills; replace those hardcoded "hsl(210, …)" values
with the CSS variables used elsewhere (e.g. var(--chart-1) for selected and a
neutral/foreground-based variable for default/hover states) so colors follow the
PR goal. Update the logic inside getCountryColor (referencing selectedCountry
and hoveredCountry) to return CSS variable-based values (or CSS
color-mix/opacity of var(--chart-1) and var(--muted)/var(--foreground) to
preserve lighter/darker variants) and also change the legend swatches rendering
(the legend swatch code that currently uses `hsl(210, 70%, ${l}%)`) to use the
same CSS variables so the map hover fill and legend gradient are neutralized
consistently.
🟡 Minor comments (18)
plugins/caveman/.codex-plugin/plugin.json-29-30 (1)

29-30: ⚠️ Potential issue | 🟡 Minor

privacyPolicyURL / termsOfServiceURL point to README and LICENSE.

These two fields advertise that the plugin has a privacy policy and terms of service, but the URLs link to README.md and LICENSE respectively, neither of which is the document the field name implies. If no real policies exist, prefer omitting the fields (if the schema allows) or hosting actual policy pages; otherwise marketplace consumers may be misled.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/caveman/.codex-plugin/plugin.json` around lines 29 - 30, Update the
plugin metadata in plugin.json to avoid misleading links: either remove the
"privacyPolicyURL" and "termsOfServiceURL" keys if you don't have actual policy
pages, or replace their values with real URLs that point to formal privacy
policy and terms documents (not README.md or LICENSE). Locate the keys
"privacyPolicyURL" and "termsOfServiceURL" in the plugin.json and ensure the
values reference dedicated policy pages or omit the keys entirely so consumers
are not misled.
plugins/caveman/skills/compress/scripts/detect.py-73-74 (1)

73-74: ⚠️ Potential issue | 🟡 Minor

.csv misclassified as code.

The ternary at line 74 returns config only for {.json, .yaml, .yml, .toml, .ini, .cfg, .env} and code for everything else in SKIP_EXTENSIONS. That means .csv (data) and .dockerfile/.makefile (build configs) are reported as code. Since the only consumer is should_compress, classification accuracy doesn't affect the skip decision, but the printed type= label in the CLI (__main__ at line 121) will be misleading for these.

Consider expanding the config set to include .csv, .dockerfile, .makefile, or simplify by mapping these explicitly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/caveman/skills/compress/scripts/detect.py` around lines 73 - 74, The
current ternary in detect.py that returns "code" vs "config" for extensions in
SKIP_EXTENSIONS misclassifies files like .csv, .dockerfile, and .makefile;
update the classification logic (the branch that checks ext) to use an explicit
CONFIG_EXTENSIONS set (or an EXT_TO_TYPE mapping) that includes ".csv",
".dockerfile", ".makefile" (and the existing ".json", ".yaml", ".yml", ".toml",
".ini", ".cfg", ".env") and return "config" when ext is in that set, otherwise
"code", so the CLI label printed in __main__ reflects the correct type and
SKIP_EXTENSIONS/should_compress behavior remains unchanged.
.codex/hooks.json-1-17 (1)

1-17: ⚠️ Potential issue | 🟡 Minor

Repo-level hook unconditionally activates caveman mode on every session.

Committing this .codex/hooks.json to the repo will force the caveman-mode preamble on every Codex session/resume for any contributor who clones the repo. If that's intended only for the author's local use, consider keeping it out of version control (e.g., move to a personal config or .gitignore it). If it's meant to be repo-wide, document that in README.md so collaborators understand why their assistant suddenly starts speaking in fragments.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.codex/hooks.json around lines 1 - 17, The repository-level SessionStart
hook in .codex/hooks.json unconditionally injects "caveman mode" on startup (see
the "SessionStart" array with matcher "startup|resume" and the command string
containing "CAVEMAN MODE ACTIVE"); remove this hook from the committed file (or
delete the entire .codex/hooks.json entry), keep a local copy instead (add to
.gitignore) if you want it for personal use, or if it must be repo-wide, keep
the hook but add documentation in README.md explaining the behavior and intent
so contributors aren't surprised.
plugins/caveman/skills/caveman/SKILL.md-44-44 (1)

44-44: ⚠️ Potential issue | 🟡 Minor

Malformed wenyan-full example.

useMemo .Wrap之 has a stray leading space/period before Wrap, mixes English ("Wrap") into what the table on line 36 describes as "Maximum classical terseness ... Fully 文言文". Compared to the surrounding examples this looks like a typo. Consider:

-- wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。"
+- wenyan-full: "物出新參照,致重繪。以 useMemo 包之。"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/caveman/skills/caveman/SKILL.md` at line 44, The `wenyan-full`
example value is malformed: remove the stray leading space/period and replace
the English "Wrap" with a fully 文言文 equivalent to match other examples (i.e.,
change `"物出新參照,致重繪。useMemo .Wrap之。"` to a corrected classical-vernacular phrase
without the extra " . " and without English, using the same tense/structure as
surrounding entries); update the `wenyan-full` mapping so it reads as a proper
文言文 sentence consistent with the table semantics.
plugins/caveman/skills/compress/scripts/benchmark.py-58-65 (1)

58-65: ⚠️ Potential issue | 🟡 Minor

tests_dir path is inconsistent with its comment.

Line 58 comments that the glob mode uses repo_root/tests/caveman-compress/, but Path(__file__).parent.parent.parent from plugins/caveman/skills/compress/scripts/benchmark.py resolves to plugins/caveman/skills/, making tests_dir = plugins/caveman/skills/tests/caveman-compress. This mismatch between comment and actual path is confusing. Additionally, tests_dir.glob("*.original.md") on line 65 is non-recursive; if test fixtures are nested in subdirectories, they will not be discovered.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/caveman/skills/compress/scripts/benchmark.py` around lines 58 - 65,
The tests_dir path calculation is wrong: tests_dir is built from
Path(__file__).parent.parent.parent (which points to plugins/caveman/skills/)
and the comment states repo_root/tests/caveman-compress; fix by locating the
repository root (e.g. walk up to repo root from Path(__file__).resolve() or use
Path(__file__).parents[...] to reach the repo root) and construct tests_dir =
<repo_root> / "tests" / "caveman-compress"; also change the discovery to be
recursive by using tests_dir.rglob("*.original.md") instead of
tests_dir.glob(...) and update the inline comment to accurately reflect the
resolved path; look for symbols tests_dir, benchmark.py, and the glob usage to
apply these edits.
apps/ingestion/src/db/migrations/0000_create_events.sql-47-47 (1)

47-47: ⚠️ Potential issue | 🟡 Minor

Redundant index on visitors.fingerprint.

The UNIQUE("fingerprint") constraint on line 47 already creates a unique btree index in Postgres, so idx_visitors_fingerprint on line 61 is duplicated storage and write overhead with no query benefit.

🛠️ Proposed fix
-CREATE INDEX "idx_visitors_last_seen" ON "visitors" USING btree ("last_seen");--> statement-breakpoint
-CREATE INDEX "idx_visitors_fingerprint" ON "visitors" USING btree ("fingerprint");
+CREATE INDEX "idx_visitors_last_seen" ON "visitors" USING btree ("last_seen");

Also applies to: 61-61

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ingestion/src/db/migrations/0000_create_events.sql` at line 47, Remove
the redundant unique btree index by deleting the explicit CREATE INDEX for
idx_visitors_fingerprint since the UNIQUE("fingerprint") constraint
(visitors_fingerprint_unique) already creates the same index; locate the
migration SQL that defines CONSTRAINT "visitors_fingerprint_unique" and the
CREATE INDEX "idx_visitors_fingerprint" and remove the CREATE INDEX statement
(or wrap it in a conditional that skips creation if the constraint exists),
ensuring no other migrations or code depend on the separate index name before
committing.
apps/ingestion/src/db/migrations/0000_create_events.sql-10-13 (1)

10-13: ⚠️ Potential issue | 🟡 Minor

Incomplete fix: is_localhost still lacks NOT NULL constraint.

Migration 0002_add_flag_columns.sql corrected three of the four boolean flag columns (is_preview, bot_detected, is_internal) to NOT NULL DEFAULT false for parity with visitors.is_internal. However, is_localhost was never updated and remains nullable in the events table. This inconsistency is still present and forces defensive IS NULL checks in queries like publicTraffic() in filters.ts. Recommend adding is_localhost to a migration: ALTER TABLE events ALTER COLUMN is_localhost SET NOT NULL.

Additionally, the TypeScript schema definition (schema.ts lines 23-26) is out of sync with the actual database schema—it still lacks .notNull() on three columns that are already NOT NULL in 0002.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ingestion/src/db/migrations/0000_create_events.sql` around lines 10 -
13, The events table still has "is_localhost" nullable—add a new migration that
runs ALTER TABLE events ALTER COLUMN is_localhost SET NOT NULL (mirror
0002_add_flag_columns.sql) and ensure the TypeScript schema (schema.ts around
lines 23-26) is updated to mark the boolean flags as .notNull() (is_preview,
bot_detected, is_internal and is_localhost if present) so the TS schema matches
the DB; after migration you can remove defensive IS NULL checks in consumers
such as publicTraffic() in filters.ts.
apps/example-dashboard/next.config.mjs-12-14 (1)

12-14: ⚠️ Potential issue | 🟡 Minor

Remove the invalid cache.directory config option.

cache.directory is not a recognized Next.js 16 configuration option and will be silently ignored. Next.js 16 handles caching through other mechanisms:

  • Build output: Use distDir to control the directory (default: .next)
  • Incremental Static Regeneration (ISR): Use incrementalCacheHandlerPath or cacheHandler
  • use cache directives: Use cacheHandlers
  • Dev filesystem caching: Enable experimental.turbopackFileSystemCacheForDev (stable in 16.1+)

If the intent is to configure caching, use the appropriate option above instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/next.config.mjs` around lines 12 - 14, Remove the
invalid Next.js config key `cache.directory` from the exported config (the
`cache: { directory: '.next/cache' }` block) because Next.js 16 does not
recognize it; if you intended to change the build output directory use
`distDir`, for ISR use `incrementalCacheHandlerPath`/`cacheHandler` or
`cacheHandlers`, and for dev filesystem caching consider
`experimental.turbopackFileSystemCacheForDev` instead—update the config to
delete the `cache.directory` entry and replace it with the appropriate supported
option for your goal.
packages/sdk/scripts/client-directive.ts-7-12 (1)

7-12: ⚠️ Potential issue | 🟡 Minor

Enforce Node ≥ 20.11 and add defensive file checks.

Two valid concerns:

  1. import.meta.dirname requires Node ≥ 20.11 (or 21.2+). Add "engines": { "node": ">=20.11" } to packages/sdk/package.json to enforce this requirement.

  2. While tsup is configured to emit both ESM and CJS formats, the script has no safeguard if a file is missing. Add existsSync check to gracefully skip:

Guard
-import { readFileSync, writeFileSync } from "node:fs";
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
 import { join } from "node:path";
 
 const files = ["index.js", "index.cjs"];
 const directive = '"use client";';
 
 for (const file of files) {
 	const path = join(import.meta.dirname, "..", "dist", file);
+	if (!existsSync(path)) continue;
 	const source = readFileSync(path, "utf8");
 	if (source.startsWith(directive)) continue;
 	writeFileSync(path, `${directive}\n${source}`);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdk/scripts/client-directive.ts` around lines 7 - 12, Add an engines
field to packages/sdk/package.json setting "node": ">=20.11" to enforce the
runtime requirement for import.meta.dirname, and update the script that iterates
over files (the for (const file of files) loop using import.meta.dirname,
readFileSync and writeFileSync) to defensively check for file existence with
existsSync before reading; if the file does not exist, skip it gracefully so the
script handles missing ESM/CJS outputs from tsup without throwing.
apps/example-dashboard/components/signal-stream.tsx-115-115 (1)

115-115: ⚠️ Potential issue | 🟡 Minor

Always-appended ellipsis on slices.

requestId.slice(0, 12) + "..." and userAgent.slice(0, 80) + "..." show "…" even when the source string is shorter than the slice limit. Minor UI polish.

💡 Suggested fix
-								{requestId.slice(0, 12)}...
+								{requestId.length > 12 ? `${requestId.slice(0, 12)}…` : requestId}
-									{userAgent.slice(0, 80)}...
+									{userAgent.length > 80 ? `${userAgent.slice(0, 80)}…` : userAgent}

Also applies to: 205-205

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/components/signal-stream.tsx` at line 115, The UI is
appending "..." unconditionally after slices causing ellipses on short strings;
update the rendering for requestId and userAgent so the ellipsis is only added
when the original string is longer than the slice limit (e.g., for requestId
check length > 12 before using slice + "..." and for userAgent check length >
80), and apply the same conditional logic to the other occurrence of
userAgent/requestId in the component (look for the JSX that references
requestId.slice(0, 12) and userAgent.slice(0, 80)).
apps/example-dashboard/components/geo-details.tsx-102-110 (1)

102-110: ⚠️ Potential issue | 🟡 Minor

City row key can collide when region is null.

region is typed string | null (Line 23). Two distinct cities with the same name in the same country and null region will produce the same React key ("Paris-null-FR"), causing the toggle state to apply to multiple rows. Including a stable identifier or row index disambiguates.

♻️ Suggestion
-							{cities.slice(0, 8).map((city) => {
-								const key = `${city.city}-${city.region}-${city.country}`;
+							{cities.slice(0, 8).map((city, index) => {
+								const key = `${city.country}-${city.region ?? ""}-${city.city}-${index}`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/components/geo-details.tsx` around lines 102 - 110,
The React key generation in the cities mapping uses
`${city.city}-${city.region}-${city.country}` which can collide when city.region
is null; update the key to include a stable unique identifier (e.g., `city.id`)
or fall back to the map index to disambiguate (so change the `key` binding and
the `expandedCity` comparison to use the new composite key), keeping
`labelText(city.city)`/`labelText(city.country)` usage intact; reference the
mapping block where `cities.slice(0, 8).map((city) => {` and the `key` and
`expandedCity` variables are defined.
apps/example-dashboard/components/dashboard-content.tsx-759-770 (1)

759-770: ⚠️ Potential issue | 🟡 Minor

Hostname check evaluates once at render and uses an env var that may be undefined.

Two small concerns:

  1. process.env.NEXT_PUBLIC_PERSONAL_DASHBOARD_HOSTNAME is replaced at build time. If the env var isn't set, isPersonalDashboard becomes hostname === undefined → always false, which is fine, but worth ensuring the var is configured in Vercel for the personal deployment (matches the PR's "Confirm DATABASE_URL is set in Vercel" test plan).
  2. The check runs on every render — moving it into the useEffect (or a useMemo) with a hostname comparison guards against SSR/CSR mismatch warnings if the component ever renders on the server.
♻️ Move into effect to avoid hydration drift
 function DemoDataNotice() {
 	const [dismissed, setDismissed] = useState(false);
-	const isPersonalDashboard = typeof window !== "undefined" && 
-		window.location.hostname === process.env.NEXT_PUBLIC_PERSONAL_DASHBOARD_HOSTNAME;
+	const [isPersonalDashboard, setIsPersonalDashboard] = useState(false);
 
 	useEffect(() => {
+		setIsPersonalDashboard(
+			window.location.hostname === process.env.NEXT_PUBLIC_PERSONAL_DASHBOARD_HOSTNAME,
+		);
 		if (sessionStorage.getItem("demo-notice-dismissed") === "true") {
 			setDismissed(true);
 		}
 	}, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/components/dashboard-content.tsx` around lines 759 -
770, The hostname check in DemoDataNotice (isPersonalDashboard) runs during
render and uses build-time env NEXT_PUBLIC_PERSONAL_DASHBOARD_HOSTNAME which may
be undefined; move that logic into a client-only effect (or initialize a state
via useEffect/useMemo) so the comparison runs only in the browser to avoid
SSR/CSR hydration drift and guard against undefined env by treating a missing
NEXT_PUBLIC_PERSONAL_DASHBOARD_HOSTNAME as non-matching; specifically, compute
isPersonalDashboard inside the existing useEffect (or set a dedicated state via
useEffect) using window.location.hostname ===
process.env.NEXT_PUBLIC_PERSONAL_DASHBOARD_HOSTNAME and then use that state in
the render-return check.
apps/example-dashboard/components/dashboard-content.tsx-117-120 (1)

117-120: ⚠️ Potential issue | 🟡 Minor

typeFilter accepts unvalidated URL input.

searchParams.get("status") is cast straight to SignalEvent["type"] | "all" without checking against the allowed set. A user-tampered URL like ?status=banana will propagate that string into <SignalStream typeFilter={...}> and the type assertion silently lies to TypeScript. Suggest narrowing to a known set with a fallback.

♻️ Suggested guard
-	const typeFilter = ((searchParams.get("status") as SignalEvent["type"] | null) || "all") as
-		| SignalEvent["type"]
-		| "all";
+	const ALLOWED_STATUS = new Set<SignalEvent["type"] | "all">(["all", "ok", "error", "warn", "info"]);
+	const rawStatus = (searchParams.get("status") ?? "all") as SignalEvent["type"] | "all";
+	const typeFilter = ALLOWED_STATUS.has(rawStatus) ? rawStatus : "all";

(adjust the allowed set to match the real SignalEvent["type"] union)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/components/dashboard-content.tsx` around lines 117 -
120, typeFilter is trusting unvalidated URL input from
searchParams.get("status") and asserting it as SignalEvent["type"] | "all";
validate the value against the allowed SignalEvent types before using it (e.g.,
build an allowedTypes set/array that mirrors SignalEvent["type"] values and
check searchParams.get("status") against it), set typeFilter to the matched
value or fallback to "all", and pass that safe value into <SignalStream
typeFilter={...}> instead of the raw asserted string.
apps/ingestion/tests/unit/ops.test.ts-12-27 (1)

12-27: ⚠️ Potential issue | 🟡 Minor

Address the hardcoded raw IP address in the test.

The test assertion about target: "custom" is correct—the parseArgs implementation intentionally sets target to "custom" whenever --url is supplied (line 264 of ops.ts), regardless of prior --target values. However, the test hardcodes "http://127.0.0.1:3000", which violates the guideline for apps/ingestion/**/*.{ts,tsx} files: never store raw IP addresses. Use an environment variable, placeholder, or mock URL instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ingestion/tests/unit/ops.test.ts` around lines 12 - 27, Replace the
hardcoded raw IP URL in the "parses cli args" test with a non-IP configurable
value: define a test URL variable (e.g., from process.env.INGESTION_TEST_URL or
a safe placeholder like "http://localhost:3000") and pass that variable into
parseArgs instead of "http://127.0.0.1:3000", then update the expect(args.url)
assertion to compare against that variable; locate the test by the test name and
the parseArgs call to make the change.
scripts/sync-vercel-env.sh-9-9 (1)

9-9: ⚠️ Potential issue | 🟡 Minor

Unused ENVS variable.

Shellcheck SC2034 flags ENVS as unused. The actual env lists are passed inline ("production preview", etc.), so this constant is dead. Either remove it or use it as the canonical source where applicable.

Suggested fix
-ENVS="production preview development"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/sync-vercel-env.sh` at line 9, The ENVS variable defined in
scripts/sync-vercel-env.sh is unused (Shellcheck SC2034); either remove the
ENVS="production preview development" declaration or replace the inline
environment lists with this constant so it becomes the canonical source; update
any occurrences that currently use literal lists (e.g., "production preview",
"production development") to reference ENVS (or remove ENVS if you opt to
inline) and run shellcheck to confirm the warning is resolved.
apps/example-dashboard/lib/queries/audience.ts-21-41 (1)

21-41: ⚠️ Potential issue | 🟡 Minor

Dead 'Unknown' fallback when filter excludes nulls.

WHERE ... AND meta->>'browser' IS NOT NULL already strips rows where the key is missing or SQL-NULL, so COALESCE(meta->>'browser', 'Unknown') can never produce 'Unknown'. Either drop the IS NOT NULL guard (let COALESCE bucket nulls into "Unknown") or drop the COALESCE. Same applies to getOSDetailed at lines 32–41.

Suggested fix (keep "Unknown" bucket)
-		await sql`SELECT COALESCE(meta->>'browser', 'Unknown') as browser, COUNT(*) as count FROM events WHERE ${publicTraffic()} AND ts >= ${from} AND ts <= ${to} AND meta->>'browser' IS NOT NULL ${projectId ? sql`AND project_id = ${projectId}` : sql``} GROUP BY browser ORDER BY count DESC LIMIT 10`;
+		await sql`SELECT COALESCE(meta->>'browser', 'Unknown') as browser, COUNT(*) as count FROM events WHERE ${publicTraffic()} AND ts >= ${from} AND ts <= ${to} ${projectId ? sql`AND project_id = ${projectId}` : sql``} GROUP BY browser ORDER BY count DESC LIMIT 10`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/lib/queries/audience.ts` around lines 21 - 41, In
getBrowsersDetailed and getOSDetailed the COALESCE(..., 'Unknown') is dead
because the query also includes meta->>'browser' IS NOT NULL / meta->>'os' IS
NOT NULL; decide to keep the "Unknown" bucket by removing those IS NOT NULL
filters (so NULL/missing values fall into COALESCE) or remove the COALESCE if
you prefer to exclude unknowns; update the SQL in getBrowsersDetailed and
getOSDetailed accordingly so the COALESCE and WHERE clauses are consistent.
apps/example-dashboard/lib/queries/content.ts-47-60 (1)

47-60: ⚠️ Potential issue | 🟡 Minor

LIKE against unsanitized domain treats %/_ as wildcards.

domain is interpolated as a parameter (so SQL injection is fine), but the surrounding '%' || domain || '%' makes any % or _ in the input act as wildcards. An empty domain matches every referrer, and _ matches any single character. Consider exact-host matching (referrer ILIKE 'http%://' || domain || '/%') or escaping the wildcards before concatenation, and reject empty strings early.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/lib/queries/content.ts` around lines 47 - 60,
getReferrerDetail currently builds referrer LIKE patterns with "%" + domain +
"%" which treats user-supplied % and _ as wildcards and allows empty domain to
match everything; update getReferrerDetail to reject empty/blank domain early,
and replace the ad-hoc LIKE pattern in both queries with either (a) host-aware
matching using a parameterized ILIKE pattern such as matching referrer ILIKE
'http%://' || domain || '/%' (or 'http%://%' || domain || '/%') to ensure
exact-host matches, or (b) escape user input before concatenating (escape % and
_ and use an explicit ESCAPE clause) so the domain is treated literally; ensure
both SQL calls in getReferrerDetail (the stats and landingPages queries) are
updated and remain parameterized to avoid injection.
apps/ingestion/scripts/ops.ts-931-938 (1)

931-938: ⚠️ Potential issue | 🟡 Minor

Hardcoded /usr/bin/fzf skips macOS Homebrew installs.

existsSync("/usr/bin/fzf") will be false for most macOS dev machines (Homebrew installs to /opt/homebrew/bin/fzf on Apple Silicon or /usr/local/bin/fzf on Intel) and any user-local install (~/.fzf/bin). Those users will silently get null from the palette command even though fzf is installed. Resolve via $PATH instead.

🛠️ Proposed fix — resolve via PATH
-			const choice = existsSync("/usr/bin/fzf") ? await chooseFzf(all) : null;
+			const choice = await hasFzf() ? await chooseFzf(all) : null;
async function hasFzf(): Promise<boolean> {
	const probe = await run(["sh", "-c", "command -v fzf"]);
	return probe.code === 0 && probe.output.trim().length > 0;
}

Alternatively, attempt chooseFzf directly and catch ENOENT from spawn.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ingestion/scripts/ops.ts` around lines 931 - 938, The palette branch
currently checks existsSync("/usr/bin/fzf") which misses Homebrew and user
installs; replace that hardcoded probe with a PATH-based check or a safe
try-call: implement a helper like hasFzf() that runs "sh -c 'command -v fzf'"
(or uses spawn and resolves true if exit code 0 and output non-empty), then use
if (await hasFzf()) ? await chooseFzf(all) : null; or simply call chooseFzf(all)
inside a try/catch and treat ENOENT as "not installed" before setting/clearing
input.setRawMode and calling handleChoice(state, choice) — update the code
around the "palette" key and the chooseFzf call accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 756539db-6a3d-4063-b858-de5570c6607c

📥 Commits

Reviewing files that changed from the base of the PR and between 24f7cf5 and 7668bb2.

⛔ Files ignored due to path filters (5)
  • bun.lock is excluded by !**/*.lock
  • plugins/caveman/assets/caveman-small.svg is excluded by !**/*.svg
  • plugins/caveman/assets/caveman.svg is excluded by !**/*.svg
  • plugins/caveman/skills/caveman/assets/caveman-small.svg is excluded by !**/*.svg
  • plugins/caveman/skills/caveman/assets/caveman.svg is excluded by !**/*.svg
📒 Files selected for processing (79)
  • .agents/plugins/marketplace.json
  • .codex/config.toml
  • .codex/hooks.json
  • apps/example-dashboard/app/api/analytics/route.ts
  • apps/example-dashboard/app/globals.css
  • apps/example-dashboard/app/layout.tsx
  • apps/example-dashboard/components/app-sidebar.tsx
  • apps/example-dashboard/components/command-palette.tsx
  • apps/example-dashboard/components/dashboard-content.tsx
  • apps/example-dashboard/components/dashboard-header.tsx
  • apps/example-dashboard/components/data-table.tsx
  • apps/example-dashboard/components/geo-details.tsx
  • apps/example-dashboard/components/geo-map.tsx
  • apps/example-dashboard/components/kpi-cards.tsx
  • apps/example-dashboard/components/signal-stream.tsx
  • apps/example-dashboard/components/trend-chart.tsx
  • apps/example-dashboard/components/world-map.tsx
  • apps/example-dashboard/lib/mock-data.ts
  • apps/example-dashboard/lib/queries.ts
  • apps/example-dashboard/lib/queries/audience.ts
  • apps/example-dashboard/lib/queries/content.ts
  • apps/example-dashboard/lib/queries/filters.ts
  • apps/example-dashboard/lib/queries/index.ts
  • apps/example-dashboard/lib/queries/kpis.ts
  • apps/example-dashboard/lib/queries/overview.ts
  • apps/example-dashboard/lib/queries/realtime.ts
  • apps/example-dashboard/lib/queries/sessions.ts
  • apps/example-dashboard/lib/types.ts
  • apps/example-dashboard/next.config.mjs
  • apps/example-dashboard/package.json
  • apps/example-dashboard/tests/integration/queries.test.ts
  • apps/example-dashboard/tests/tsconfig.json
  • apps/example-dashboard/tsconfig.json
  • apps/example-dashboard/tsconfig.tsbuildinfo
  • apps/ingestion/package.json
  • apps/ingestion/scripts/ops.ts
  • apps/ingestion/src/app.ts
  • apps/ingestion/src/db/client.ts
  • apps/ingestion/src/db/index.ts
  • apps/ingestion/src/db/migrations/0000_create_events.sql
  • apps/ingestion/src/db/migrations/0001_archive_legacy_tables.sql
  • apps/ingestion/src/db/migrations/0002_add_flag_columns.sql
  • apps/ingestion/src/db/migrations/001_add_missing_columns.sql
  • apps/ingestion/src/db/migrations/meta/0000_snapshot.json
  • apps/ingestion/src/db/migrations/meta/_journal.json
  • apps/ingestion/src/db/schema.ts
  • apps/ingestion/src/db/seed.ts
  • apps/ingestion/src/handlers/admin.ts
  • apps/ingestion/src/handlers/ingest.ts
  • apps/ingestion/src/handlers/metrics.ts
  • apps/ingestion/src/utilities/dedupe.ts
  • apps/ingestion/src/utilities/geo.ts
  • apps/ingestion/tests/unit/ingest.test.ts
  • apps/ingestion/tests/unit/ops.test.ts
  • apps/ingestion/tests/unit/schema.test.ts
  • package.json
  • packages/sdk/__tests__/track.test.ts
  • packages/sdk/package.json
  • packages/sdk/scripts/client-directive.ts
  • packages/sdk/src/api/track.ts
  • packages/sdk/src/components/analytics.tsx
  • packages/sdk/src/index.ts
  • packages/sdk/src/observers/performance.ts
  • packages/sdk/src/types/index.ts
  • packages/sdk/src/utilities/enrichment.ts
  • packages/sdk/tsconfig.json
  • packages/sdk/tsup.config.ts
  • plugins/caveman/.codex-plugin/plugin.json
  • plugins/caveman/skills/caveman/SKILL.md
  • plugins/caveman/skills/caveman/agents/openai.yaml
  • plugins/caveman/skills/compress/SKILL.md
  • plugins/caveman/skills/compress/scripts/__init__.py
  • plugins/caveman/skills/compress/scripts/__main__.py
  • plugins/caveman/skills/compress/scripts/benchmark.py
  • plugins/caveman/skills/compress/scripts/cli.py
  • plugins/caveman/skills/compress/scripts/compress.py
  • plugins/caveman/skills/compress/scripts/detect.py
  • plugins/caveman/skills/compress/scripts/validate.py
  • scripts/sync-vercel-env.sh
💤 Files with no reviewable changes (2)
  • packages/sdk/src/index.ts
  • apps/example-dashboard/lib/queries.ts

},
};

const analyticsUrl = process.env.NEXT_PUBLIC_ANALYTICS_URL || "https://ingestion.remcostoeten.nl";
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Hardcoded fallback leaks analytics to a personal domain.

process.env.NEXT_PUBLIC_ANALYTICS_URL || "https://ingestion.remcostoeten.nl" means any fork, preview, or misconfigured deploy that omits the env var will silently send pageviews to a single author-controlled endpoint. Two safer options:

  • Keep the previous "empty string / no-op" behavior so missing config is a no-op rather than a cross-tenant leak.
  • Throw at boot in non-development when the env var is missing, so the misconfiguration is loud.

The PR test plan calls out "Confirm DATABASE_URL is set in Vercel for both apps" — applying the same explicit-config discipline to the public ingest URL would be consistent.

🛡️ Proposed fix (no-op fallback)
-const analyticsUrl = process.env.NEXT_PUBLIC_ANALYTICS_URL || "https://ingestion.remcostoeten.nl";
+const analyticsUrl = process.env.NEXT_PUBLIC_ANALYTICS_URL ?? "";
📝 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 analyticsUrl = process.env.NEXT_PUBLIC_ANALYTICS_URL || "https://ingestion.remcostoeten.nl";
const analyticsUrl = process.env.NEXT_PUBLIC_ANALYTICS_URL ?? "";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/app/layout.tsx` at line 33, Replace the hardcoded
personal-domain fallback for the NEXT_PUBLIC_ANALYTICS_URL by making
analyticsUrl a no-op when unset: change the initialization of the analyticsUrl
constant (the symbol analyticsUrl) to use process.env.NEXT_PUBLIC_ANALYTICS_URL
|| "" (empty string) so missing env config does not leak to a third-party
endpoint; alternatively, if you prefer loud failures in non-development, add a
boot-time check where, when process.env.NEXT_PUBLIC_ANALYTICS_URL is falsy and
NODE_ENV !== "development", the app throws or logs a fatal error to prevent
startup—update the usage sites that expect analyticsUrl accordingly so they skip
sending telemetry when analyticsUrl is falsy.

Comment on lines +3 to +15
import { publicTraffic, getRange, getTimeRangeFilter, formatNumber, calculateTrend } from "./filters";
import {
getPageviewsKPI,
getUniqueVisitorsKPI,
getSessionsKPI,
getEventsKPI,
getBotRateKPI,
getErrorCountKPI,
getLocalhostRateKPI,
} from "./kpis";
import { getTopPages, getTopReferrers, getGeoDistribution, getEntryExitPages } from "./content";
import { getDeviceBreakdown } from "./audience";
import { getRecentEvents } from "./realtime";
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Remove unused imports — currently breaking CI.

ESLint is failing on formatNumber, calculateTrend (line 3) and getEntryExitPages (line 13), which are imported but never used in this module. The entryPages/exitPages fields below are returned as empty arrays anyway, so either drop the imports or actually wire getEntryExitPages into Promise.all and the response.

🛠️ Proposed minimal fix (just remove unused)
-import { publicTraffic, getRange, getTimeRangeFilter, formatNumber, calculateTrend } from "./filters";
+import { publicTraffic, getRange, getTimeRangeFilter } from "./filters";
@@
-import { getTopPages, getTopReferrers, getGeoDistribution, getEntryExitPages } from "./content";
+import { getTopPages, getTopReferrers, getGeoDistribution } from "./content";
🧰 Tools
🪛 GitHub Actions: ci

[error] 3-3: ESLint (no-unused-vars): Identifier 'formatNumber' is imported but never used.


[error] 3-3: ESLint (no-unused-vars): Identifier 'calculateTrend' is imported but never used.


[error] 13-13: ESLint (no-unused-vars): Identifier 'getEntryExitPages' is imported but never used.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/lib/queries/overview.ts` around lines 3 - 15, The
imports formatNumber and calculateTrend and the import getEntryExitPages are
unused and causing ESLint/CI failures; either remove those three imports from
the top of this module or wire getEntryExitPages into the data fetch so
entryPages/exitPages are populated. To fix quickly, delete formatNumber and
calculateTrend from the import list on the first line and remove
getEntryExitPages from the content import, or alternatively add
getEntryExitPages to the Promise.all call that gathers KPIs/content and map its
result into the entryPages and exitPages response fields so the symbols
(getEntryExitPages, entryPages, exitPages, Promise.all) are actually used.

Comment on lines +60 to +99
const results = await sql`
SELECT
id,
fingerprint,
first_seen,
last_seen,
visit_count,
COALESCE(device_type, 'Unknown') as device_type,
COALESCE(browser, 'Unknown') as browser,
COALESCE(os, 'Unknown') as os,
COALESCE(browser_version, 'Unknown') as browser_version,
COALESCE(os_version, 'Unknown') as os_version,
COALESCE(screen_resolution, 'Unknown') as screen_resolution,
COALESCE(language, 'Unknown') as language,
country,
region,
city
FROM visitors
WHERE EXISTS (SELECT 1 FROM events WHERE events.visitor_id = visitors.fingerprint AND ${publicTrafficEvents()} AND events.ts >= ${range.from} AND events.ts <= ${range.to} ${projectId ? sql`AND events.project_id = ${projectId}` : sql``})
ORDER BY last_seen DESC
LIMIT ${limit}
`;
return results.map((r) => ({
id: String(r.id),
fingerprint: r.fingerprint,
firstSeen: r.first_seen,
lastSeen: r.last_seen,
visitCount: Number(r.visit_count),
isInternal: false,
deviceType: r.device_type,
browser: r.browser,
os: r.os,
browserVersion: r.browser_version,
osVersion: r.os_version,
screenResolution: r.screen_resolution,
language: r.language,
country: COUNTRY_NAME_TO_ISO[r.country] || r.country,
region: r.region,
city: r.city,
}));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

isInternal is hardcoded to false and ignores the column in visitors.

The visitors table has an is_internal column (per the new migration snapshot), but this query doesn't SELECT it and the mapper sets isInternal: false unconditionally. Internal visitors will be misclassified in any UI consuming this list.

Suggested fix
       COALESCE(language, 'Unknown') as language,
       country,
       region,
-      city
+      city,
+      is_internal
     FROM visitors
@@
-		isInternal: false,
+		isInternal: Boolean(r.is_internal),
📝 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 results = await sql`
SELECT
id,
fingerprint,
first_seen,
last_seen,
visit_count,
COALESCE(device_type, 'Unknown') as device_type,
COALESCE(browser, 'Unknown') as browser,
COALESCE(os, 'Unknown') as os,
COALESCE(browser_version, 'Unknown') as browser_version,
COALESCE(os_version, 'Unknown') as os_version,
COALESCE(screen_resolution, 'Unknown') as screen_resolution,
COALESCE(language, 'Unknown') as language,
country,
region,
city
FROM visitors
WHERE EXISTS (SELECT 1 FROM events WHERE events.visitor_id = visitors.fingerprint AND ${publicTrafficEvents()} AND events.ts >= ${range.from} AND events.ts <= ${range.to} ${projectId ? sql`AND events.project_id = ${projectId}` : sql``})
ORDER BY last_seen DESC
LIMIT ${limit}
`;
return results.map((r) => ({
id: String(r.id),
fingerprint: r.fingerprint,
firstSeen: r.first_seen,
lastSeen: r.last_seen,
visitCount: Number(r.visit_count),
isInternal: false,
deviceType: r.device_type,
browser: r.browser,
os: r.os,
browserVersion: r.browser_version,
osVersion: r.os_version,
screenResolution: r.screen_resolution,
language: r.language,
country: COUNTRY_NAME_TO_ISO[r.country] || r.country,
region: r.region,
city: r.city,
}));
const results = await sql`
SELECT
id,
fingerprint,
first_seen,
last_seen,
visit_count,
COALESCE(device_type, 'Unknown') as device_type,
COALESCE(browser, 'Unknown') as browser,
COALESCE(os, 'Unknown') as os,
COALESCE(browser_version, 'Unknown') as browser_version,
COALESCE(os_version, 'Unknown') as os_version,
COALESCE(screen_resolution, 'Unknown') as screen_resolution,
COALESCE(language, 'Unknown') as language,
country,
region,
city,
is_internal
FROM visitors
WHERE EXISTS (SELECT 1 FROM events WHERE events.visitor_id = visitors.fingerprint AND ${publicTrafficEvents()} AND events.ts >= ${range.from} AND events.ts <= ${range.to} ${projectId ? sql`AND events.project_id = ${projectId}` : sql``})
ORDER BY last_seen DESC
LIMIT ${limit}
`;
return results.map((r) => ({
id: String(r.id),
fingerprint: r.fingerprint,
firstSeen: r.first_seen,
lastSeen: r.last_seen,
visitCount: Number(r.visit_count),
isInternal: Boolean(r.is_internal),
deviceType: r.device_type,
browser: r.browser,
os: r.os,
browserVersion: r.browser_version,
osVersion: r.os_version,
screenResolution: r.screen_resolution,
language: r.language,
country: COUNTRY_NAME_TO_ISO[r.country] || r.country,
region: r.region,
city: r.city,
}));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/lib/queries/realtime.ts` around lines 60 - 99, The
query in realtime.ts currently ignores the visitors.is_internal column and
hardcodes isInternal: false; update the SQL SELECT (the query that assigns const
results = await sql`...`) to include is_internal (e.g., SELECT ..., is_internal,
...) and then update the results.map(...) mapper to set isInternal based on the
returned row (e.g., map r.is_internal to isInternal using a boolean conversion)
so internal visitors are correctly represented; the relevant symbols are the SQL
block that calls publicTrafficEvents(), range.from, range.to, projectId, LIMIT
${limit}, the const results variable, and the results.map(...) mapping function.

export async function getRetention(projectId: string | null) {
const fiveWeeksAgo = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000);
const retention =
await sql`WITH visitor_cohorts AS (SELECT visitor_id, DATE_TRUNC('week', MIN(ts)) as cohort_week FROM events WHERE ${publicTraffic()} AND TRUE ${projectId ? sql`AND project_id = ${projectId}` : sql``} GROUP BY visitor_id), weekly_activity AS (SELECT e.visitor_id, vc.cohort_week, DATE_TRUNC('week', e.ts) as activity_week, EXTRACT(WEEK FROM e.ts) - EXTRACT(WEEK FROM vc.cohort_week) as weeks_since_cohort FROM events e JOIN visitor_cohorts vc ON e.visitor_id = vc.visitor_id WHERE e.ts >= ${fiveWeeksAgo} ${projectId ? sql`AND e.project_id = ${projectId}` : sql``}) SELECT cohort_week, weeks_since_cohort::int as week_number, COUNT(DISTINCT visitor_id) as visitors FROM weekly_activity WHERE weeks_since_cohort >= 0 AND weeks_since_cohort <= 4 GROUP BY cohort_week, weeks_since_cohort ORDER BY cohort_week, week_number`;
Copy link
Copy Markdown

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

🌐 Web query:

PostgreSQL EXTRACT WEEK across year boundary behavior

💡 Result:

PostgreSQL's EXTRACT(WEEK FROM date) follows the ISO 8601 week-numbering system. Weeks start on Monday, and the first week of a year is the one containing January 4th (i.e., the week with the first Thursday of the year). Across year boundaries: - Early January dates may belong to the previous year's week 52 or 53. Examples: 2005-01-01 is week 53 of 2004; 2006-01-01 is week 52 of 2005; 2016-01-03 may be week 53 of 2015. - Late December dates may belong to the next year's week 1. Example: 2012-12-31 is week 1 of 2013. To get consistent week-year pairing, use EXTRACT(ISOYEAR FROM date) with EXTRACT(WEEK FROM date). EXTRACT(YEAR FROM date) gives the Gregorian year, which may differ. This behavior is unchanged in recent versions (up to PostgreSQL 18 docs).

Citations:


🏁 Script executed:

find . -name "sessions.ts" -type f | head -20

Repository: remcostoeten/analytics

Length of output: 115


🏁 Script executed:

cat -n ./apps/example-dashboard/lib/queries/sessions.ts

Repository: remcostoeten/analytics

Length of output: 7023


Fix week-boundary bug in retention calculation.

EXTRACT(WEEK FROM date) returns ISO week 1–53 and resets yearly. Cohorts spanning late December to early January produce negative weeks_since_cohort (e.g., 1 - 52 = -49) and are silently filtered out by WHERE weeks_since_cohort >= 0 AND weeks_since_cohort <= 4, corrupting retention stats. Subtract timestamp objects directly instead:

Fix
-		await sql`WITH visitor_cohorts AS (SELECT visitor_id, DATE_TRUNC('week', MIN(ts)) as cohort_week FROM events WHERE ${publicTraffic()} AND TRUE ${projectId ? sql`AND project_id = ${projectId}` : sql``} GROUP BY visitor_id), weekly_activity AS (SELECT e.visitor_id, vc.cohort_week, DATE_TRUNC('week', e.ts) as activity_week, EXTRACT(WEEK FROM e.ts) - EXTRACT(WEEK FROM vc.cohort_week) as weeks_since_cohort FROM events e JOIN visitor_cohorts vc ON e.visitor_id = vc.visitor_id WHERE e.ts >= ${fiveWeeksAgo} ${projectId ? sql`AND e.project_id = ${projectId}` : sql``}) SELECT cohort_week, weeks_since_cohort::int as week_number, COUNT(DISTINCT visitor_id) as visitors FROM weekly_activity WHERE weeks_since_cohort >= 0 AND weeks_since_cohort <= 4 GROUP BY cohort_week, weeks_since_cohort ORDER BY cohort_week, week_number`;
+		await sql`WITH visitor_cohorts AS (SELECT visitor_id, DATE_TRUNC('week', MIN(ts)) as cohort_week FROM events WHERE ${publicTraffic()} ${projectId ? sql`AND project_id = ${projectId}` : sql``} GROUP BY visitor_id), weekly_activity AS (SELECT e.visitor_id, vc.cohort_week, DATE_TRUNC('week', e.ts) as activity_week, ((DATE_TRUNC('week', e.ts) - vc.cohort_week) / INTERVAL '1 week')::int as weeks_since_cohort FROM events e JOIN visitor_cohorts vc ON e.visitor_id = vc.visitor_id WHERE e.ts >= ${fiveWeeksAgo} ${projectId ? sql`AND e.project_id = ${projectId}` : sql``}) SELECT cohort_week, weeks_since_cohort as week_number, COUNT(DISTINCT visitor_id) as visitors FROM weekly_activity WHERE weeks_since_cohort >= 0 AND weeks_since_cohort <= 4 GROUP BY cohort_week, weeks_since_cohort ORDER BY cohort_week, week_number`;

Also removes stray AND TRUE.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/lib/queries/sessions.ts` at line 54, The retention
query in sessions.ts uses EXTRACT(WEEK FROM ...) subtraction
(weeks_since_cohort) which breaks across year boundaries and also contains a
stray "AND TRUE"; replace the weeks_since_cohort expression in the
weekly_activity CTE with a proper week-difference computed from truncated
timestamps (e.g. compute weeks_since_cohort as FLOOR(EXTRACT(EPOCH FROM
(DATE_TRUNC('week', e.ts) - DATE_TRUNC('week', vc.cohort_week))) / 604800) or an
equivalent interval/CAST-to-int calculation) and remove the unnecessary "AND
TRUE" in the visitor_cohorts WHERE clause; keep the existing uses of
visitor_cohorts, weekly_activity, fiveWeeksAgo, publicTraffic() and projectId
filters intact.

Comment on lines +3 to +5
experimental: {
optimizePackageInputs: true,
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Next.js 16 experimental optimizePackageInputs vs optimizePackageImports

💡 Result:

In Next.js 16, there is no experimental.optimizePackageInputs option. The correct and only documented experimental configuration for optimizing package imports is experimental.optimizePackageImports. experimental.optimizePackageImports is an experimental feature that tree-shakes large packages with many exports (e.g., icon libraries like lucide-react, date-fns, lodash-es). It analyzes barrel exports and loads only the used modules, improving dev and prod performance by reducing bundle sizes and startup times. Configuration example: module.exports = { experimental: { optimizePackageImports: ['package-name'], }, }; Several popular libraries are optimized by default, including lucide-react, date-fns, antd, @mui/material, recharts, and others (full list in docs). This feature remains under experimental in Next.js 16, as no changes were noted in release notes or upgrading guides. It is not recommended for production due to potential changes. No sources mention optimizePackageInputs; searches across docs, blogs, GitHub issues, and changelogs returned zero results for it. It may be a misspelling or confusion with optimizePackageImports. Related options include serverExternalPackages (for excluding from server bundling) but differ in purpose.

Citations:


Fix typo: optimizePackageInputs should be optimizePackageImports

optimizePackageInputs is not a valid Next.js experimental flag and will be silently ignored. The correct option is optimizePackageImports, which accepts an array of package names to tree-shake for better performance.

🔧 Suggested fix
 	experimental: {
-		optimizePackageInputs: true,
+		optimizePackageImports: ['lucide-react', 'recharts', 'date-fns'],
 	},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/next.config.mjs` around lines 3 - 5, The Next.js
config uses the invalid experimental property `optimizePackageInputs`; update
the `experimental` object in next.config.mjs to use `optimizePackageImports`
instead and provide the appropriate array of package names to tree-shake (e.g.,
set experimental.optimizePackageImports = ['package-a', 'package-b']) so the
flag is recognized and effective; locate and replace the `optimizePackageInputs`
key in the `experimental` block in next.config.mjs.

Comment on lines +6 to +7
CREATE INDEX CONCURRENTLY IF NOT EXISTS events_is_preview_idx ON events (is_preview);
CREATE INDEX CONCURRENTLY IF NOT EXISTS events_bot_detected_idx ON events (bot_detected);
Copy link
Copy Markdown

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
fd -t f 'drizzle.config' apps/ingestion --exec cat
fd -e sql . apps/ingestion/src/db/migrations --exec head -5 {}

Repository: remcostoeten/analytics

Length of output: 1033


🏁 Script executed:

cat -n apps/ingestion/src/db/migrations/0002_add_flag_columns.sql

Repository: remcostoeten/analytics

Length of output: 949


CREATE INDEX CONCURRENTLY cannot run inside a transaction block.

Drizzle Kit wraps each migration file in a single transaction by default. Your drizzle.config.ts contains no breakpoint directives or transaction-disabling configuration, so this migration will fail with ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block.

Fix this by removing CONCURRENTLY (acceptable for a fresh Neon DB where the events table is empty and lock contention is not a concern):

Recommended fix
-CREATE INDEX CONCURRENTLY IF NOT EXISTS events_is_preview_idx  ON events (is_preview);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS events_bot_detected_idx ON events (bot_detected);
+CREATE INDEX IF NOT EXISTS events_is_preview_idx  ON events (is_preview);
+CREATE INDEX IF NOT EXISTS events_bot_detected_idx ON events (bot_detected);
📝 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
CREATE INDEX CONCURRENTLY IF NOT EXISTS events_is_preview_idx ON events (is_preview);
CREATE INDEX CONCURRENTLY IF NOT EXISTS events_bot_detected_idx ON events (bot_detected);
CREATE INDEX IF NOT EXISTS events_is_preview_idx ON events (is_preview);
CREATE INDEX IF NOT EXISTS events_bot_detected_idx ON events (bot_detected);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ingestion/src/db/migrations/0002_add_flag_columns.sql` around lines 6 -
7, The migration uses "CREATE INDEX CONCURRENTLY" which fails because Drizzle
Kit wraps migrations in a transaction; edit the migration in
0002_add_flag_columns.sql and change the two statements that create
events_is_preview_idx and events_bot_detected_idx to use plain "CREATE INDEX"
(remove the CONCURRENTLY keyword) so the migration can run inside Drizzle's
transaction, or alternatively adjust Drizzle config/breakpoint to disable
transactional wrapping if you intentionally need CONCURRENTLY.

Comment on lines +1 to +2
ALTER TABLE events ADD COLUMN IF NOT EXISTS is_preview BOOLEAN DEFAULT FALSE;
ALTER TABLE events ADD COLUMN IF NOT EXISTS bot_detected BOOLEAN DEFAULT FALSE; No newline at end of file
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect migration journal and any other migration filenames for naming/ordering inconsistencies.
fd -t f . apps/ingestion/src/db/migrations
fd -t f 'journal\.json$' apps/ingestion/src/db/migrations -x cat {}
rg -nP -C2 'is_preview|bot_detected' apps/ingestion/src/db/migrations

Repository: remcostoeten/analytics

Length of output: 6552


Delete 001_add_missing_columns.sql — it is redundant and uses incorrect naming.

This file is a pure no-op:

  1. Redundancy: 0000_create_events.sql (lines 11–12) already defines both is_preview and bot_detected with DEFAULT false. The IF NOT EXISTS clause ensures this file does nothing on a fresh database, making its presence misleading and unnecessary.

  2. Naming violation: The filename uses a 3-digit prefix (001_) while Drizzle's convention is 4-digit (0000_, 0001_, 0002_). Additionally, the _journal.json does not track this file — only 0000_create_events is listed — indicating Drizzle Kit did not generate it and will not recognize it as a managed migration.

  3. Additional redundancy: 0002_add_flag_columns.sql also adds these same columns. While that file includes legitimate UPDATE logic to populate values from JSON metadata, this file (001_add_missing_columns.sql) adds nothing beyond the redundant ALTER statements.

Delete this file and clean up the migrations directory.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ingestion/src/db/migrations/001_add_missing_columns.sql` around lines 1
- 2, Remove the redundant migration file 001_add_missing_columns.sql: delete the
file from the migrations directory because its ALTER TABLE statements for
is_preview and bot_detected are already defined in 0000_create_events.sql and
also handled (with update logic) in 0002_add_flag_columns.sql, and the filename
violates the 4-digit Drizzle convention and is not tracked in the migrations
_journal.json; after deleting, ensure the migrations folder and the journal only
contain the intended Drizzle-managed files (e.g., 0000_create_events.sql,
0002_add_flag_columns.sql) so the migration sequence and naming conventions
remain consistent.

Comment on lines 41 to 58
export function resolveDefaultIngestUrl(): string {
const env = getEnv();
const url = env.NEXT_PUBLIC_ANALYTICS_URL || env.VITE_ANALYTICS_URL || "http://localhost:3001";
const url = env.NEXT_PUBLIC_ANALYTICS_URL || env.VITE_ANALYTICS_URL;

if (!url) {
if (typeof window !== "undefined") {
console.error("[Analytics] No ingest URL configured. Set NEXT_PUBLIC_ANALYTICS_URL or VITE_ANALYTICS_URL.");
}
return "";
}

if (typeof window !== "undefined" && !validateIngestUrl(url)) {
console.error(`[Analytics] Invalid ingestUrl: "${url}". Must be a valid http/https URL.`);
return "http://localhost:3001";
return "";
}

return url;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Normalize the env-derived URL to avoid //e in the request path.

resolveDefaultIngestUrl validates a normalized copy but returns the raw url. If NEXT_PUBLIC_ANALYTICS_URL (or VITE_ANALYTICS_URL) is set with a trailing slash (e.g. https://api.example.com/), the caller computes endpoint = "${baseUrl}/e" and ends up with https://api.example.com//e. The options.ingestUrl path already runs through normalizeIngestUrl, but the env path does not.

🛠️ Proposed fix
 export function resolveDefaultIngestUrl(): string {
 	const env = getEnv();
 	const url = env.NEXT_PUBLIC_ANALYTICS_URL || env.VITE_ANALYTICS_URL;

 	if (!url) {
 		if (typeof window !== "undefined") {
 			console.error("[Analytics] No ingest URL configured. Set NEXT_PUBLIC_ANALYTICS_URL or VITE_ANALYTICS_URL.");
 		}
 		return "";
 	}

 	if (typeof window !== "undefined" && !validateIngestUrl(url)) {
 		console.error(`[Analytics] Invalid ingestUrl: "${url}". Must be a valid http/https URL.`);
 		return "";
 	}

-	return url;
+	return normalizeIngestUrl(url);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdk/src/api/track.ts` around lines 41 - 58, resolveDefaultIngestUrl
currently validates the raw env URL but returns it unnormalized, causing double
slashes when callers append paths; update resolveDefaultIngestUrl to normalize
the env-derived URL before returning (use the existing normalizeIngestUrl helper
and/or validate the normalized value with validateIngestUrl) so the function
returns a cleaned URL string (reference symbols: resolveDefaultIngestUrl,
normalizeIngestUrl, validateIngestUrl).

Comment on lines +200 to +225
# Step 2: Validate + Retry
for attempt in range(MAX_RETRIES):
print(f"\nValidation attempt {attempt + 1}")

result = validate(backup_path, filepath)

if result.is_valid:
print("Validation passed")
break

print("❌ Validation failed:")
for err in result.errors:
print(f" - {err}")

if attempt == MAX_RETRIES - 1:
# Restore original on failure
filepath.write_text(original_text)
backup_path.unlink(missing_ok=True)
print("❌ Failed after retries — original restored")
return False

print("Fixing with Claude...")
compressed = call_claude(
build_fix_prompt(original_text, compressed, result.errors)
)
filepath.write_text(compressed)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Retry budget is off by one — only 1 fix attempt actually runs.

MAX_RETRIES = 2 combined with range(MAX_RETRIES) means the loop executes twice, but each iteration is validate-then-(maybe)-fix with the last iteration short-circuiting on failure. Trace:

  • attempt 0: validate → fail → call Claude fix → write
  • attempt 1: validate → fail → restore original, return False

So Claude only ever gets one chance to fix validation errors, not two. But SKILL.md line 33 says "retry up to 2 times" and the PR description echoes that. To actually allow up to two fix attempts, the loop needs to validate one more time after the second fix:

♻️ Proposed fix
-MAX_RETRIES = 2
+MAX_FIX_ATTEMPTS = 2
@@
-    # Step 2: Validate + Retry
-    for attempt in range(MAX_RETRIES):
-        print(f"\nValidation attempt {attempt + 1}")
-
-        result = validate(backup_path, filepath)
-
-        if result.is_valid:
-            print("Validation passed")
-            break
-
-        print("❌ Validation failed:")
-        for err in result.errors:
-            print(f"   - {err}")
-
-        if attempt == MAX_RETRIES - 1:
-            # Restore original on failure
-            filepath.write_text(original_text)
-            backup_path.unlink(missing_ok=True)
-            print("❌ Failed after retries — original restored")
-            return False
-
-        print("Fixing with Claude...")
-        compressed = call_claude(
-            build_fix_prompt(original_text, compressed, result.errors)
-        )
-        filepath.write_text(compressed)
-
-    return True
+    # Step 2: Validate, then up to MAX_FIX_ATTEMPTS fix passes
+    for attempt in range(MAX_FIX_ATTEMPTS + 1):
+        print(f"\nValidation attempt {attempt + 1}")
+        result = validate(backup_path, filepath)
+        if result.is_valid:
+            print("Validation passed")
+            return True
+
+        print("❌ Validation failed:")
+        for err in result.errors:
+            print(f"   - {err}")
+
+        if attempt == MAX_FIX_ATTEMPTS:
+            filepath.write_text(original_text)
+            backup_path.unlink(missing_ok=True)
+            print("❌ Failed after retries — original restored")
+            return False
+
+        print("Fixing with Claude...")
+        compressed = call_claude(
+            build_fix_prompt(original_text, compressed, result.errors)
+        )
+        filepath.write_text(compressed)
+
+    return False
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/caveman/skills/compress/scripts/compress.py` around lines 200 - 225,
The loop currently restores on the last iteration before giving Claude a final
fix, so with MAX_RETRIES=2 you only get one fix attempt; change the branch so
that after a failed validation you only restore/return when attempt ==
MAX_RETRIES - 1, otherwise call_claude to produce a new compressed and write it;
in other words, inside the for attempt in range(MAX_RETRIES) block keep
validate(result), print errors, then if attempt < MAX_RETRIES - 1
call_claude(build_fix_prompt(original_text, compressed, result.errors)) and
filepath.write_text(compressed) to retry, but if attempt == MAX_RETRIES - 1
restore original_text to filepath and backup_path.unlink(...) and return False.

Comment on lines +28 to +39
set_env() {
local name="$1" value="$2" envs="$3"
for env in $envs; do
if env_exists "$name" "$env"; then
log "Removing existing $name ($env)..."
echo | vercel env rm "$name" "$env" --yes 2>/dev/null || true
fi
log "Setting $name ($env)..."
echo "$value" | vercel env add "$name" "$env" >/dev/null 2>&1
ok "$name → $env"
done
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

vercel env add failures are silenced — script reports ok on failure.

>/dev/null 2>&1 swallows stderr from vercel env add, so a failed add (network error, auth issue, validation failure) still falls through to ok "$name → $env". Same issue with the rm call: only 2>/dev/null || true, but at minimum the add should propagate failure.

Suggested fix
     log "Setting $name ($env)..."
-    echo "$value" | vercel env add "$name" "$env" >/dev/null 2>&1
-    ok "$name → $env"
+    if echo "$value" | vercel env add "$name" "$env" >/dev/null; then
+      ok "$name → $env"
+    else
+      warn "Failed to set $name → $env"
+      exit 1
+    fi
📝 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
set_env() {
local name="$1" value="$2" envs="$3"
for env in $envs; do
if env_exists "$name" "$env"; then
log "Removing existing $name ($env)..."
echo | vercel env rm "$name" "$env" --yes 2>/dev/null || true
fi
log "Setting $name ($env)..."
echo "$value" | vercel env add "$name" "$env" >/dev/null 2>&1
ok "$name$env"
done
}
set_env() {
local name="$1" value="$2" envs="$3"
for env in $envs; do
if env_exists "$name" "$env"; then
log "Removing existing $name ($env)..."
echo | vercel env rm "$name" "$env" --yes 2>/dev/null || true
fi
log "Setting $name ($env)..."
if echo "$value" | vercel env add "$name" "$env" >/dev/null; then
ok "$name$env"
else
warn "Failed to set $name$env"
exit 1
fi
done
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/sync-vercel-env.sh` around lines 28 - 39, The set_env function
currently silences failures from vercel commands so the script always prints ok
even when vercel env add fails; change set_env to stop discarding vercel env add
errors (remove the >/dev/null 2>&1), capture and check the exit status of the
vercel env add call (and similarly avoid swallowing rm failures in the
env_exists removal path), and only call ok "$name → $env" when the add
succeeded; on failure log an error with the command output (use log/error
functions) and exit non‑zero or return a non‑zero status so callers can detect
the failure—refer to set_env, env_exists, ok and log in the diff to locate where
to make these changes.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
apps/example-dashboard/lib/queries/skriuw.ts (3)

23-32: Operational: consider expression indexes on the JSONB extractions used as filters/group keys.

Every Skriuw query filters or groups on meta->>'eventName' (and one on meta->>'method', one on meta->>'query'). Combined with project_id and ts predicates, these will benefit from expression indexes once the events table grows, e.g.:

CREATE INDEX events_meta_event_name_ts_idx
  ON events (project_id, ((meta->>'eventName')), ts DESC)
  WHERE type = 'event';

Worth pairing with the new Drizzle migrations being added in this PR so the dashboard doesn't regress on a hot path once Skriuw traffic scales.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/lib/queries/skriuw.ts` around lines 23 - 32, The
queries in skriuw.ts repeatedly filter/group on JSONB extractions
(meta->>'eventName', meta->>'method', meta->>'query') combined with project_id
and ts (and WHERE type = 'event'), which will need expression indexes to stay
performant; add Drizzle migration(s) that create expression indexes on the
events table such as indexes over (project_id, ((meta->>'eventName')), ts DESC)
with WHERE type = 'event' and analogous indexes for ((meta->>'method')) and
((meta->>'query')) so the SELECT/ GROUP BY in the SQL query that references
meta->>'eventName' and the other queries use the new indexes.

116-137: Use a named type for the return shape instead of Record<string, unknown>[].

The function shapes the result into a known structure (ts, path, eventName, meta, visitorId, sessionId), but the return type erases all of it, forcing every caller to re-type or guess the fields. Define a RecentEvent type and return RecentEvent[] so the route handler and any UI consumers get type safety.

♻️ Proposed fix
+export type RecentEvent = {
+  ts: unknown;
+  path: unknown;
+  eventName: unknown;
+  meta: unknown;
+  visitorId: unknown;
+  sessionId: unknown;
+};
+
-export async function getSkriuwRecentEvents(projectId: string, limit: number = 50, from?: Date, to?: Date): Promise<Record<string, unknown>[]> {
+export async function getSkriuwRecentEvents(projectId: string, limit: number = 50, from?: Date, to?: Date): Promise<RecentEvent[]> {

(Tighten the inner field types — Date, string | null, etc. — once you confirm what neon returns for each column.)

As per coding guidelines: "Use type, never interface" — and using a typed shape over Record<string, unknown> keeps the API contract explicit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/lib/queries/skriuw.ts` around lines 116 - 137, Define
a named return type RecentEvent and use it instead of Record<string, unknown>[]
for getSkriuwRecentEvents: add a type RecentEvent = { ts: Date; path: string |
null; eventName: string; meta: any; visitorId: string | null; sessionId: string
| null } (adjust nullable types to match Neon column behavior), change the
function signature to Promise<RecentEvent[]>, and ensure the results.map in
getSkriuwRecentEvents returns objects matching RecentEvent (use the existing
mapped fields ts, path, eventName, meta, visitorId, sessionId).

20-114: Optional: extract a shared event-aggregation helper.

getSkriuwEventCounts, getSkriuwNotesActivity, getSkriuwJournalActivity, and getSkriuwAuthMetrics share the same query skeleton (date range + publicTraffic() + project_id + type='event'), differing only in the SELECT key and the eventName predicate. A small helper that takes the SELECT key ('eventName' / 'method') and a list of eventName filters would deduplicate ~80% of these bodies and make future schema changes single-touch. Not blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/lib/queries/skriuw.ts` around lines 20 - 114, The four
functions getSkriuwEventCounts, getSkriuwNotesActivity, getSkriuwJournalActivity
and getSkriuwAuthMetrics repeat the same query skeleton; extract a single helper
(e.g., aggregateEventByKey) that accepts parameters for projectId, range
(from/to via getRange), the JSON key to SELECT (meta->>'eventName' or
meta->>'method'), optional eventName filter list, GROUP/ORDER clauses, and the
publicTraffic() predicate, build and run the parametrized SQL, and return
normalized { keyValue, count } rows; then refactor each of the four functions to
call this helper (mapping keyValue to eventName or method and converting count
to Number) so the DB logic is centralized and future schema/condition changes
are single-touch.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/example-dashboard/app/api/analytics/route.ts`:
- Around line 110-112: The case "skriuw-recent" uses
parseInt(searchParams.get("limit") || "50") without a radix or NaN handling,
allowing NaN or excessively large values to reach query.getSkriuwRecentEvents;
change to parse with a radix (parseInt(..., 10)), validate the result (fallback
to a safe default like 50 on NaN), and clamp it to a reasonable max (e.g. 1000)
before passing to query.getSkriuwRecentEvents; also wrap the case body in braces
so skriuwLimit is block-scoped (resolving the noSwitchDeclarations warning) and
then call NextResponse.json(await query.getSkriuwRecentEvents(projectFilter ??
"skriuw", skriuwLimit, from, to)).
- Around line 100-114: The handler is incorrectly hardcoding "skriuw" as a
fallback (projectFilter ?? "skriuw") for all skriuw metrics which leaks
tenant-specific behavior; change the seven cases to pass projectFilter (allowing
undefined) instead of forcing "skriuw", and update the corresponding query
functions in lib/queries/skriuw.ts (e.g., getSkriuwEventCounts,
getSkriuwEventTrend, getSkriuwNotesActivity, getSkriuwJournalActivity,
getSkriuwAuthMetrics, getSkriuwRecentEvents, getSkriuwTopSearches) to accept
projectId?: string and omit the "AND project_id = ..." predicate when projectId
is not provided so the route remains multi-tenant and unfiltered when no
projectFilter is supplied.

---

Nitpick comments:
In `@apps/example-dashboard/lib/queries/skriuw.ts`:
- Around line 23-32: The queries in skriuw.ts repeatedly filter/group on JSONB
extractions (meta->>'eventName', meta->>'method', meta->>'query') combined with
project_id and ts (and WHERE type = 'event'), which will need expression indexes
to stay performant; add Drizzle migration(s) that create expression indexes on
the events table such as indexes over (project_id, ((meta->>'eventName')), ts
DESC) with WHERE type = 'event' and analogous indexes for ((meta->>'method'))
and ((meta->>'query')) so the SELECT/ GROUP BY in the SQL query that references
meta->>'eventName' and the other queries use the new indexes.
- Around line 116-137: Define a named return type RecentEvent and use it instead
of Record<string, unknown>[] for getSkriuwRecentEvents: add a type RecentEvent =
{ ts: Date; path: string | null; eventName: string; meta: any; visitorId: string
| null; sessionId: string | null } (adjust nullable types to match Neon column
behavior), change the function signature to Promise<RecentEvent[]>, and ensure
the results.map in getSkriuwRecentEvents returns objects matching RecentEvent
(use the existing mapped fields ts, path, eventName, meta, visitorId,
sessionId).
- Around line 20-114: The four functions getSkriuwEventCounts,
getSkriuwNotesActivity, getSkriuwJournalActivity and getSkriuwAuthMetrics repeat
the same query skeleton; extract a single helper (e.g., aggregateEventByKey)
that accepts parameters for projectId, range (from/to via getRange), the JSON
key to SELECT (meta->>'eventName' or meta->>'method'), optional eventName filter
list, GROUP/ORDER clauses, and the publicTraffic() predicate, build and run the
parametrized SQL, and return normalized { keyValue, count } rows; then refactor
each of the four functions to call this helper (mapping keyValue to eventName or
method and converting count to Number) so the DB logic is centralized and future
schema/condition changes are single-touch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4d754468-881d-4915-b06c-2ac6c0d90cf5

📥 Commits

Reviewing files that changed from the base of the PR and between 7668bb2 and 239f502.

📒 Files selected for processing (4)
  • apps/example-dashboard/app/api/analytics/route.ts
  • apps/example-dashboard/lib/queries/index.ts
  • apps/example-dashboard/lib/queries/skriuw.ts
  • apps/example-dashboard/tsconfig.tsbuildinfo
✅ Files skipped from review due to trivial changes (1)
  • apps/example-dashboard/lib/queries/index.ts

Comment on lines +100 to +114
case "skriuw-events":
return NextResponse.json(await query.getSkriuwEventCounts(projectFilter ?? "skriuw", from, to));
case "skriuw-trend":
return NextResponse.json(await query.getSkriuwEventTrend(projectFilter ?? "skriuw", from, to));
case "skriuw-notes":
return NextResponse.json(await query.getSkriuwNotesActivity(projectFilter ?? "skriuw", from, to));
case "skriuw-journal":
return NextResponse.json(await query.getSkriuwJournalActivity(projectFilter ?? "skriuw", from, to));
case "skriuw-auth":
return NextResponse.json(await query.getSkriuwAuthMetrics(projectFilter ?? "skriuw", from, to));
case "skriuw-recent":
const skriuwLimit = parseInt(searchParams.get("limit") || "50");
return NextResponse.json(await query.getSkriuwRecentEvents(projectFilter ?? "skriuw", skriuwLimit, from, to));
case "skriuw-searches":
return NextResponse.json(await query.getSkriuwTopSearches(projectFilter ?? "skriuw", 20, from, to));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Hardcoded "skriuw" project fallback leaks tenant-specific behavior into the shared API route.

Every new branch uses projectFilter ?? "skriuw", but elsewhere in this handler the convention is to pass projectFilter (undefined → unfiltered) or projectId (null). Consequences:

  • A request like GET /api/analytics?metric=skriuw-events with no projectId silently scopes results to a project literally identified by the string "skriuw". On any deployment whose project id isn't "skriuw" (or whose schema uses UUIDs), this returns empty data with no error.
  • It bakes a single-tenant assumption into a route that the rest of the file treats as multi-project.

The root cause is that getSkriuw* in lib/queries/skriuw.ts declares projectId: string as required. Recommend making projectId optional in those query functions (omit the AND project_id = ... predicate when not provided), and then dropping the ?? "skriuw" here so behavior matches the other case branches.

♻️ Proposed direction

In skriuw.ts:

-export async function getSkriuwEventCounts(projectId: string, from?: Date, to?: Date): Promise<EventCount[]> {
+export async function getSkriuwEventCounts(projectId?: string, from?: Date, to?: Date): Promise<EventCount[]> {
   const range = getRange(from, to);
-  const results =
-    await sql`... AND project_id = ${projectId} ...`;
+  const projectClause = projectId ? sql`AND project_id = ${projectId}` : sql``;
+  const results =
+    await sql`... ${projectClause} ...`;

In route.ts:

-  case "skriuw-events":
-    return NextResponse.json(await query.getSkriuwEventCounts(projectFilter ?? "skriuw", from, to));
+  case "skriuw-events":
+    return NextResponse.json(await query.getSkriuwEventCounts(projectFilter, from, to));

(Apply to all seven skriuw-* cases.)

🧰 Tools
🪛 Biome (2.4.12)

[error] 111-111: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

(lint/correctness/noSwitchDeclarations)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/app/api/analytics/route.ts` around lines 100 - 114,
The handler is incorrectly hardcoding "skriuw" as a fallback (projectFilter ??
"skriuw") for all skriuw metrics which leaks tenant-specific behavior; change
the seven cases to pass projectFilter (allowing undefined) instead of forcing
"skriuw", and update the corresponding query functions in lib/queries/skriuw.ts
(e.g., getSkriuwEventCounts, getSkriuwEventTrend, getSkriuwNotesActivity,
getSkriuwJournalActivity, getSkriuwAuthMetrics, getSkriuwRecentEvents,
getSkriuwTopSearches) to accept projectId?: string and omit the "AND project_id
= ..." predicate when projectId is not provided so the route remains
multi-tenant and unfiltered when no projectFilter is supplied.

Comment on lines +110 to +112
case "skriuw-recent":
const skriuwLimit = parseInt(searchParams.get("limit") || "50");
return NextResponse.json(await query.getSkriuwRecentEvents(projectFilter ?? "skriuw", skriuwLimit, from, to));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

parseInt without radix or NaN guard for limit.

parseInt(searchParams.get("limit") || "50") will return NaN for non-numeric inputs (e.g. ?limit=abc), which then flows into the SQL LIMIT clause and 500s. Also no upper bound, so ?limit=1000000 is allowed. Validate and clamp at the boundary.

🛡️ Proposed fix
-		case "skriuw-recent":
-			const skriuwLimit = parseInt(searchParams.get("limit") || "50");
-			return NextResponse.json(await query.getSkriuwRecentEvents(projectFilter ?? "skriuw", skriuwLimit, from, to));
+		case "skriuw-recent": {
+			const parsed = Number(searchParams.get("limit"));
+			const skriuwLimit = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 500) : 50;
+			return NextResponse.json(await query.getSkriuwRecentEvents(projectFilter, skriuwLimit, from, to));
+		}

The added braces also resolve the Biome noSwitchDeclarations warning on line 111 by scoping skriuwLimit to its own block.

📝 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
case "skriuw-recent":
const skriuwLimit = parseInt(searchParams.get("limit") || "50");
return NextResponse.json(await query.getSkriuwRecentEvents(projectFilter ?? "skriuw", skriuwLimit, from, to));
case "skriuw-recent": {
const parsed = Number(searchParams.get("limit"));
const skriuwLimit = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 500) : 50;
return NextResponse.json(await query.getSkriuwRecentEvents(projectFilter, skriuwLimit, from, to));
}
🧰 Tools
🪛 Biome (2.4.12)

[error] 111-111: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

(lint/correctness/noSwitchDeclarations)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/app/api/analytics/route.ts` around lines 110 - 112,
The case "skriuw-recent" uses parseInt(searchParams.get("limit") || "50")
without a radix or NaN handling, allowing NaN or excessively large values to
reach query.getSkriuwRecentEvents; change to parse with a radix (parseInt(...,
10)), validate the result (fallback to a safe default like 50 on NaN), and clamp
it to a reasonable max (e.g. 1000) before passing to
query.getSkriuwRecentEvents; also wrap the case body in braces so skriuwLimit is
block-scoped (resolving the noSwitchDeclarations warning) and then call
NextResponse.json(await query.getSkriuwRecentEvents(projectFilter ?? "skriuw",
skriuwLimit, from, to)).

@remcostoeten remcostoeten merged commit 239f502 into master Apr 27, 2026
5 of 7 checks passed
@remcostoeten remcostoeten deleted the feature/type-safe-ingestion-sdk-cleanup branch April 30, 2026 02:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant