Skip to content

feat(analytics): public anonymous stats with never-stored visitor hashes - #31

Open
ImGajeed76 wants to merge 1 commit into
mainfrom
dev
Open

feat(analytics): public anonymous stats with never-stored visitor hashes#31
ImGajeed76 wants to merge 1 commit into
mainfrom
dev

Conversation

@ImGajeed76

Copy link
Copy Markdown
Contributor

Anonymous, all-public analytics for docolin. Every figure is public, no stored
row describes a single reading session, and counts never affect ranking.

An edge Worker writes Analytics Engine datapoints on every request including
cache hits, an hourly cron rolls them into Neon, and the completed day is
exported as CC0 NDJSON. Public pages at /stats, /stats/{org}/{project} and
/stats/{org}/{project}/{path}, plus an MCP tool.

Invariants worth checking

These are the claims the feature makes. If any is false the feature is wrong,
so they are the useful things to review.

  • No visitor identifier is stored. The edge HMACs the address with a per-year
    secret, keeps one HLL (register, rank) pair and discards the hash.
  • Each scope hashes under its own domain, so a reader's doco, project and site
    registers cannot be correlated.
  • k=25 floor on breakdowns, applied by one predicate that both the live pages
    and the CC0 export call.
  • Country is never broken out for a single doco, including a project that has
    shrunk to one.
  • Open-ended buckets (kind path, tab label, language, referrer host) are
    narrowed and then checked against something real, not just shaped.

Where the risk is

  • src/lib/locale-tag.ts gates whether a doco syncs at all. It went through
    several wrong answers before landing on CLDR expansion; display-name lists
    differ per runtime and refused Tibetan in production only.
  • src/lib/server/analytics/storage.ts is the rollup. A throw there stalls the
    cursor and re-runs the same day forever, so bad input has to be dropped
    rather than raised.
  • workers/edge/ and src/routes/api/e/ are public unauthenticated writes.
  • drizzle/0033_analytics.sql is hand-authored and schema/analytics.ts is a
    hand-kept mirror. The migration wins on any disagreement.

Already verified, to save duplicate effort

  • Sync canonicalizer output byte-identical across the docs corpus and a 1500
    case fuzz after the shared parser extraction.
  • Renderer output byte-identical for every file in docs/ after the same change.
  • HLL rollup rebuilds sketches bit-identically against real data.
  • Locale handling executed inside workerd, not only Bun.

Before this records anything in production

  • ANALYTICS_SECRET_<year>, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_ANALYTICS_TOKEN
  • migration 0033 on prod (applied on the dev branch only)
  • the edge Worker route, deliberately still undeclared pending the routing spike

Testing

bun run test covers three runtimes: the app on Bun, worker code on Bun, and
anything runtime-dependent on workerd via vitest-pool-workers. bun run check
now also dry-run builds both Workers.

Every figure is public, no stored row describes a single reading session,
and counts never affect ranking. An edge Worker writes Analytics Engine
datapoints, an hourly cron rolls them into Neon, and the completed day is
exported as CC0.

Uniques are HyperLogLog: the edge HMACs the address with a per-year secret,
keeps one (register, rank) pair and discards the hash, so no visitor
identifier is ever stored. The salt epoch is the calendar year, the largest
window over which a union stays lossless. Breakdowns sit behind a k=25
floor applied by one predicate that both the live pages and the CC0 export
call, so the open data cannot disagree with the site.

drizzle/0033_analytics.sql is hand-authored: three tables are PARTITION BY
RANGE and the hll columns carry a type modifier drizzle-kit drops, so
schema/analytics.ts is a hand-kept mirror and the migration wins on any
disagreement. Partitions must stay ahead of writes; once rows land in
DEFAULT, creating that month fails.

Gotchas

RENDERER_VERSION -> 8, because the table of contents and the excerpt now
parse with the renderer's own phase. Both are baked into cached HTML, and
the purge runs on the next sync cron tick rather than on deploy.

The client must never import a path containing "events": content blockers
refuse the request and take the component down. Modules bundled into a
Worker cannot use SvelteKit aliases, which an eslint rule now enforces.

Tests run on three runtimes, workerd included, because a locale check built
on Intl.DisplayNames passed every Bun test while refusing Tibetan on the
runtime that ships.
@ImGajeed76

Copy link
Copy Markdown
Contributor Author

@greptile-apps review

@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown
❌ Action failed

Review failed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 181 files, which is 81 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3b55dada-b710-4bd4-a35f-dce466d6f305

📥 Commits

Reviewing files that changed from the base of the PR and between 31c9f9e and 10b6429.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (181)
  • .claude/agents/style-reviewer.md
  • .env.example
  • .neon
  • .prettierignore
  • AGENTS.md
  • CLAUDE.md
  • docs/authoring/frontmatter.md
  • docs/concepts/analytics.md
  • docs/concepts/attribution.md
  • docs/concepts/doco_sitemap.yaml
  • docs/concepts/overview.md
  • docs/concepts/privacy.md
  • docs/concepts/verification.md
  • docs/mcp/tools.md
  • docs/privacy.md
  • docs/terms.md
  • drizzle/0033_analytics.sql
  • drizzle/meta/0033_snapshot.json
  • drizzle/meta/_journal.json
  • eslint.config.js
  • messages/de.json
  • messages/en.json
  • messages/fr.json
  • package.json
  • scripts/cron-dev.ts
  • src/app.d.ts
  • src/hooks.server.ts
  • src/lib/analytics/beacon.test.ts
  • src/lib/analytics/beacon.ts
  • src/lib/analytics/buckets.test.ts
  • src/lib/analytics/buckets.ts
  • src/lib/analytics/cc0-export.test.ts
  • src/lib/analytics/cc0-export.ts
  • src/lib/analytics/chart-day.ts
  • src/lib/analytics/counter-rollup.test.ts
  • src/lib/analytics/counter-rollup.ts
  • src/lib/analytics/epoch.test.ts
  • src/lib/analytics/epoch.ts
  • src/lib/analytics/events.test.ts
  • src/lib/analytics/events.ts
  • src/lib/analytics/hll.test.ts
  • src/lib/analytics/hll.ts
  • src/lib/analytics/key-separator.ts
  • src/lib/analytics/meta.test.ts
  • src/lib/analytics/meta.ts
  • src/lib/analytics/origin.ts
  • src/lib/analytics/partition-util.test.ts
  • src/lib/analytics/partition-util.ts
  • src/lib/analytics/percent.ts
  • src/lib/analytics/public-stats.ts
  • src/lib/analytics/reader-actions.ts
  • src/lib/analytics/rollup-days.test.ts
  • src/lib/analytics/rollup-days.ts
  • src/lib/analytics/stats-series.test.ts
  • src/lib/analytics/stats-series.ts
  • src/lib/analytics/subject-stats.ts
  • src/lib/analytics/ua.test.ts
  • src/lib/analytics/ua.ts
  • src/lib/analytics/unique-windows.test.ts
  • src/lib/analytics/unique-windows.ts
  • src/lib/analytics/uuid.ts
  • src/lib/analytics/version-selection.test.ts
  • src/lib/analytics/version-selection.ts
  • src/lib/analytics/visitor.test.ts
  • src/lib/analytics/visitor.ts
  • src/lib/chart-colors.test.ts
  • src/lib/chart-colors.ts
  • src/lib/client/analytics.ts
  • src/lib/client/clipboard.ts
  • src/lib/client/search-analytics.ts
  • src/lib/client/track-dwell.ts
  • src/lib/components/Footer.svelte
  • src/lib/components/SearchCommand.svelte
  • src/lib/components/analytics/AnalyticsDistribution.svelte
  • src/lib/components/analytics/CountryBreakdown.svelte
  • src/lib/components/analytics/CountryMap.svelte
  • src/lib/components/analytics/DocoEvidenceContent.svelte
  • src/lib/components/analytics/PublicActivityChart.svelte
  • src/lib/components/analytics/PublicStatsView.svelte
  • src/lib/components/analytics/PublicVersionActivityChart.svelte
  • src/lib/components/analytics/PublicVersionActivityTooltip.svelte
  • src/lib/components/analytics/StatsTrustFooter.svelte
  • src/lib/components/analytics/UniqueReadersYearChart.svelte
  • src/lib/components/analytics/VersionCompareSelect.svelte
  • src/lib/components/doco/DocoStatsChart.svelte
  • src/lib/components/doco/DocoStatsRail.svelte
  • src/lib/components/doco/DocoStatsTooltip.svelte
  • src/lib/components/doco/DocoView.svelte
  • src/lib/components/doco/PangoScoreRail.svelte
  • src/lib/components/home/Hero.svelte
  • src/lib/components/markdown/DiffTable.svelte
  • src/lib/components/markdown/MarkdownChart.svelte
  • src/lib/components/mcp/McpConnect.svelte
  • src/lib/components/mcp/McpHero.svelte
  • src/lib/components/preview/PreviewBraveHelp.svelte
  • src/lib/components/preview/PreviewErrorBlock.svelte
  • src/lib/components/profile/UserProfile.svelte
  • src/lib/components/search/SearchResultCard.svelte
  • src/lib/components/search/types.ts
  • src/lib/components/ui/chart/chart-tooltip.svelte
  • src/lib/doco/viewer-data.ts
  • src/lib/locale-tag.ts
  • src/lib/markdown/charts.ts
  • src/lib/markdown/content-tabs.ts
  • src/lib/markdown/copy-code.ts
  • src/lib/markdown/inline-copy.ts
  • src/lib/markdown/parse-phase.ts
  • src/lib/markdown/render/chart-data.test.ts
  • src/lib/markdown/render/chart-data.ts
  • src/lib/markdown/render/excerpt.test.ts
  • src/lib/markdown/render/index.ts
  • src/lib/preview/render-doco.ts
  • src/lib/server/analytics/pango-history.ts
  • src/lib/server/analytics/partitions.ts
  • src/lib/server/analytics/project-scope.ts
  • src/lib/server/analytics/public-stats.ts
  • src/lib/server/analytics/storage.ts
  • src/lib/server/analytics/tab-labels.test.ts
  • src/lib/server/analytics/tab-labels.ts
  • src/lib/server/analytics/wae.ts
  • src/lib/server/db/schema/analytics.ts
  • src/lib/server/db/schema/docos.ts
  • src/lib/server/doco-content.ts
  • src/lib/server/markdown.ts
  • src/lib/server/mcp/server.ts
  • src/lib/server/mcp/tools.ts
  • src/lib/server/profile.ts
  • src/lib/site.test.ts
  • src/lib/site.ts
  • src/lib/sync/ai-prompt.ts
  • src/lib/sync/body-pipeline.ts
  • src/lib/sync/cache-purge-targets.test.ts
  • src/lib/sync/cache-purge-targets.ts
  • src/lib/sync/cache-purge.ts
  • src/lib/sync/frontmatter-schema.test.ts
  • src/lib/sync/frontmatter-schema.ts
  • src/lib/sync/parse.test.ts
  • src/lib/sync/parse.ts
  • src/lib/verification/recompute-core.test.ts
  • src/lib/verification/recompute-core.ts
  • src/routes/[org=org]/[project]/[...path]/+page.server.ts
  • src/routes/[org=org]/[project]/[...path]/+page.svelte
  • src/routes/[root=kind]/[...rest]/+page.svelte
  • src/routes/api/cron/export-analytics/+server.ts
  • src/routes/api/cron/recompute-scores/+server.ts
  • src/routes/api/cron/rollup-stats/+server.ts
  • src/routes/api/e/+server.ts
  • src/routes/api/e/navigation/+server.ts
  • src/routes/api/mcp/+server.ts
  • src/routes/api/search/+server.ts
  • src/routes/api/stats/doco/[docoId]/+server.ts
  • src/routes/api/stats/project/[projectId]/+server.ts
  • src/routes/api/stats/site/+server.ts
  • src/routes/api/versions/[versionId]/pango-score/+server.ts
  • src/routes/browse/+page.svelte
  • src/routes/dashboard/[org]/[project]/+page.svelte
  • src/routes/dashboard/[org]/[project]/settings/+page.server.ts
  • src/routes/dashboard/[org]/[project]/settings/+page.svelte
  • src/routes/dashboard/claims/[uid]/+page.svelte
  • src/routes/dashboard/mcp/+page.server.ts
  • src/routes/dashboard/mcp/+page.svelte
  • src/routes/layout.css
  • src/routes/mcp/+page.server.ts
  • src/routes/raw/[org]/[project]/[...path]/+server.ts
  • src/routes/search/+page.svelte
  • src/routes/signout/+server.ts
  • src/routes/stats/+page.svelte
  • src/routes/stats/[org=org]/[project]/+page.server.ts
  • src/routes/stats/[org=org]/[project]/+page.svelte
  • src/routes/stats/[org=org]/[project]/[...path]/+page.server.ts
  • src/routes/stats/[org=org]/[project]/[...path]/+page.svelte
  • static/data/README.md
  • static/data/world-countries.geojson
  • test/workerd/locale-tag.test.ts
  • vitest.workers.config.ts
  • workers/cron/index.ts
  • workers/cron/wrangler.toml
  • workers/edge/index.test.ts
  • workers/edge/index.ts
  • workers/edge/wrangler.toml
  • wrangler.toml

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Review on demand using usage pricing

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.

@greptile-apps

greptile-apps Bot commented Aug 17, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds anonymous edge analytics, Neon rollups, privacy-gated public statistics, daily CC0 exports, and an MCP analytics tool.

  • Records request and client-action counters plus domain-separated HLL registers without persisting visitor hashes.
  • Adds scheduled rollup and export workers, analytics schemas, public APIs, pages, charts, and documentation.
  • Centralizes minimum-reader publication floors, but two publication and attribution paths do not preserve the intended privacy and ownership boundaries.

Confidence Score: 1/5

The PR should not merge until cross-project beacon attribution and single-doco country disclosure in the CC0 export are fixed.

The navigation fallback persists an unverified project association that the rollup treats as authoritative, while the export path omits a privacy predicate required to suppress retained country data.

Files Needing Attention: src/routes/api/e/navigation/+server.ts, src/lib/analytics/counter-rollup.ts, src/lib/analytics/cc0-export.ts

Security Review

Two integrity and privacy boundary issues were identified: navigation beacons can attribute activity to an unrelated project, and the CC0 export can expose retained country rows for projects that have shrunk to one doco.

Important Files Changed

Filename Overview
workers/edge/index.ts Adds request analytics and domain-separated visitor-register writes; no retained visitor hash was identified.
src/routes/api/e/navigation/+server.ts Adds fallback navigation collection but trusts an independently supplied project ID, allowing cross-project attribution.
src/lib/analytics/counter-rollup.ts Aggregates observations across subjects but prefers untrusted observation project IDs over database-backed doco context.
src/lib/server/analytics/storage.ts Adds idempotent daily replacement, totals deltas, malformed-register dropping, and monthly sketch rebuilding.
src/lib/server/analytics/public-stats.ts Applies reader floors and current single-doco country suppression to live statistics.
src/lib/analytics/cc0-export.ts Applies shared k-floors to exports but omits the live path's single-doco country restriction.
drizzle/0033_analytics.sql Introduces the persisted analytics schema and constraints; no accepted schema-specific defect was established.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  R[Reader request] --> E[Edge Worker]
  N[Navigation beacon] --> A[Event ingestion]
  E --> AE[Analytics Engine]
  A --> AE
  AE --> C[Hourly rollup]
  C --> DB[(Neon aggregates)]
  DB --> L[Live stats predicate]
  DB --> X[CC0 export predicate]
  L --> P[Public stats pages and MCP]
  X --> D[Daily NDJSON]
Loading
Prompt To Fix All With AI
### Issue 1
src/routes/api/e/navigation/+server.ts:31-37
**Client project ID controls attribution**

When a navigation beacon supplies a real doco ID with another valid project UUID, this route writes the supplied project ID and the rollup treats it as authoritative, causing page-request and geographic statistics to be attributed to an unrelated project.

**How this was verified:** The public route writes `meta.projectId` directly, and the rollup prefers every nonempty observation project ID over the doco's database-backed project context.

### Issue 2
src/lib/analytics/cc0-export.ts:53-60
**Export omits country privacy gate**

When a project accumulates country rows while publishing multiple docos and later shrinks to one, this filter exports those retained rows without the live path's current project-scope check, exposing the single doco's country distribution in the public NDJSON export.

**How this was verified:** The export filter only checks bucket and read totals, while the live stats path explicitly suppresses retained country rows after checking the project's current published-doco count.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "feat(analytics): public anonymous stats ..." | Re-trigger Greptile

Comment on lines +31 to +37
buildEventDataPoint("page_request", {
docoId: meta.docoId,
projectId: meta.projectId,
uaClass,
botFamily,
versionId: meta.versionId,
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Client project ID controls attribution

When a navigation beacon supplies a real doco ID with another valid project UUID, this route writes the supplied project ID and the rollup treats it as authoritative, causing page-request and geographic statistics to be attributed to an unrelated project.

How this was verified: The public route writes meta.projectId directly, and the rollup prefers every nonempty observation project ID over the doco's database-backed project context.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/routes/api/e/navigation/+server.ts
Line: 31-37

Comment:
**Client project ID controls attribution**

When a navigation beacon supplies a real doco ID with another valid project UUID, this route writes the supplied project ID and the rollup treats it as authoritative, causing page-request and geographic statistics to be attributed to an unrelated project.

**How this was verified:** The public route writes `meta.projectId` directly, and the rollup prefers every nonempty observation project ID over the doco's database-backed project context.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +53 to +60
return rows.filter(
(row) =>
row.bucket.length === 0 ||
publishesBreakdownBucket(
Number(row.n),
readTotals.get(subjectKey(row.subject_type, row.subject_id)) ?? 0,
),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Export omits country privacy gate

When a project accumulates country rows while publishing multiple docos and later shrinks to one, this filter exports those retained rows without the live path's current project-scope check, exposing the single doco's country distribution in the public NDJSON export.

How this was verified: The export filter only checks bucket and read totals, while the live stats path explicitly suppresses retained country rows after checking the project's current published-doco count.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/analytics/cc0-export.ts
Line: 53-60

Comment:
**Export omits country privacy gate**

When a project accumulates country rows while publishing multiple docos and later shrinks to one, this filter exports those retained rows without the live path's current project-scope check, exposing the single doco's country distribution in the public NDJSON export.

**How this was verified:** The export filter only checks bucket and read totals, while the live stats path explicitly suppresses retained country rows after checking the project's current published-doco count.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@ImGajeed76

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review skipped: 181 files exceed the limit of 100.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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