Vibecode Fathom Analytics
track this build4 phases, 12 steps, beginner friendly0%A simple privacy analytics clone is very buildable; the paid service is hosted compliance posture, uptime, data retention, and polished reporting.
You are building a lean indie version of Fathom Analytics. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Fathom Analytics · indie build A web analytics service whose measurement approach is dictated by the conditions of an exemption: no storage on the device, no cross-site identity, no third-party sharing, and honest reporting of the uncertainty that those constraints introduce. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Collection | A small script sending an event with no device storage | storing anything on the device is the thing that triggers consent | | Identity | A rotating daily salted hash, never a persistent identifier | a visitor should not be linkable across days, let alone across sites | | Aggregation | Counts computed and stored, raw events discarded quickly | short retention is a condition of the exemption and a real protection | | Reporting | Figures with their uncertainty stated | measurement without identifiers is approximate and should say so | | Runtime | TypeScript with PostgreSQL | the work is ingestion, aggregation and honest presentation | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs ingestion, aggregation and reporting. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: High-volume event ingestion and scheduled aggregation with reliable deletion. - Get it: Docker or your package manager. - Verify: psql connects and a scheduled deletion runs correctly - [ ] **Your legal position, written down** · free to read; advice worth having if this is commercial - Why: The exemption conditions become architecture in this build, and which apply to you depends on where your visitors are and which regulator you are answering to. - Get it: Read the audience measurement guidance for the jurisdictions your visitors are in and write down the conditions you are relying on. - Verify: A written note lists the conditions and each maps to a constraint in the build - [ ] **A real site with real traffic** · free - Why: Blocking rates, referrer quality and the difference between your figures and another tool's only appear against real visitors. - Get it: Your own site, running both this and an existing tool for a fortnight so the figures can be compared. - Verify: Two weeks of parallel measurement exist for comparison - [ ] **A browser you can inspect storage in** · free - Why: The central claim is that nothing is written to the device, and that is verified by looking rather than by reading the code. - Get it: Any browser with developer tools, plus a blocker installed to test the blocked case. - Verify: Storage is confirmed empty after browsing several pages ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Individual-level funnels, cohorts and attribution. - Legal advice about your jurisdiction. - Cross-site or cross-device measurement. - Feature parity with tools that identify people. - managed infrastructure - privacy/compliance messaging - email reports - uptime - support - billing If one of those is essential to you, that is the reason to keep paying for Fathom Analytics, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Fathom Analytics The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me website analytics that measures usefully without following anyone to replace Fathom Analytics. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The consent question is more subtle than not using cookies, and getting it wrong is the whole point of building this rather than using something else. The rule in the European Union is about storing or accessing information on a device rather than about cookies specifically. A tool that genuinely stores nothing on the visitor's device falls outside that trigger, but the European Data Protection Board's guidelines read it broadly enough that pixels, identifiers in URLs and reading device information can all be caught, so no cookies does not automatically mean no consent required. Separately, some regulators recognise an audience measurement exemption, and the French one is the clearest: measurement strictly for the publisher, no cross-site tracking, no sharing with third parties, anonymised addresses and limited retention. Those conditions are cumulative, so adding one third-party integration removes the whole exemption. The United Kingdom regulator has been less permissive. A proposal from November 2025 would add an EU-wide exemption for first-party aggregated measurement, but as of mid 2026 it is a proposal rather than law. So the build's constraints are the exemption's conditions, treated as architecture. Nothing on the device, nothing shared, nothing cross-site, and a retention period short enough to state. ### Stack (fixed, do not substitute) - Collection: A small script sending an event with no device storage · storing anything on the device is the thing that triggers consent - Identity: A rotating daily salted hash, never a persistent identifier · a visitor should not be linkable across days, let alone across sites - Aggregation: Counts computed and stored, raw events discarded quickly · short retention is a condition of the exemption and a real protection - Reporting: Figures with their uncertainty stated · measurement without identifiers is approximate and should say so - Runtime: TypeScript with PostgreSQL · the work is ingestion, aggregation and honest presentation ### Data model (create this before Phase 1) - sites(id, hostname, timezone, created_at, retention_days) - events(id, site_id, at, path, referrer_host, country, device_class, visitor_hash) · deleted after a short window - visitor_hash · derived from a daily rotating salt, so it cannot link across days and is not reversible - daily_rollups(site_id, date, path, views, visitors, referrer_host, country, device_class) - salts(date, value, rotated_at, destroyed_at) · destroyed after the window, which is what makes the hash unrecoverable - goals(id, site_id, name, path_pattern, created_at) and goal_rollups(goal_id, date, count) - The daily salt is destroyed after rotation, which means yesterday's hashes cannot be recomputed even by you, and that is the property the whole model rests on. ### Phase 1 · Measuring without storing anything Build: A small script sending a request with the path, referrer and screen class, storing nothing on the device. Verify no cookie, storage entry or database is written, in a real browser. Keep the script small and asynchronous. Trap: A library that writes a single item to browser storage for deduplication reintroduces the consent trigger, and the whole reason for building this disappears for the sake of a small counting improvement. Assert in a real browser that nothing is written, rather than assuming your code does not. Done when: A real browser session records views with no cookie, local storage or database entry created, verified by inspection after browsing several pages. The script is small enough to state its size and loads asynchronously. Blocking the script degrades the site not at all. Requests carry no identifier assigned by the server. Do not build yet: No visitor counting, aggregation or reporting. ### Phase 2 · Counting visitors without following them Build: Derive a visitor hash from a daily rotating salt and coarse request attributes. Destroy the salt after its window so the hash cannot be recomputed. Handle the day boundary in the site's own timezone. Trap: Keeping the salt after rotation means the hashes remain recomputable and therefore linkable, which quietly turns a design that avoided identifiers into one that has them and simply does not use them yet. Destroy the salt, and verify it is gone rather than merely unused. Done when: A visitor browsing several pages in a day counts once. The same visitor on two days is not linkable, verified by confirming the previous salt is destroyed rather than retained. Day boundaries follow the site's timezone rather than the server's. The hash cannot be reversed to an address, which is checked rather than assumed. Do not build yet: No aggregation or reporting yet. ### Phase 3 · Aggregating and forgetting Build: Roll events into daily aggregates and delete the raw events after a short stated window. Make the retention period visible to the site owner. Ensure a deletion request is meaningless because there is nothing to delete. Trap: Keeping raw events indefinitely because storage is cheap turns a measurement tool into a behavioural log, which is exactly the category the exemption does not cover, and nobody notices because the interface only shows aggregates. Delete on schedule and verify the deletion runs rather than trusting it. Done when: Raw events are deleted after the stated window and the aggregates remain, verified by advancing the clock. The retention period is visible to the site owner rather than buried. Aggregates cannot be used to reconstruct an individual's session. The deletion job runs on schedule and its failure is alerted on. Do not build yet: No reporting or goals yet. ### Phase 4 · Reporting the uncertainty honestly Build: Report views, visitors, referrers and countries with a stated note on how visitors are counted and what that misses. Do not present approximate figures to more precision than they support. Show blocked-request estimates where possible. Trap: Presenting a visitor count as an exact figure implies a precision that a cookieless daily hash cannot provide, and site owners then compare it against another tool and conclude one is broken. State the counting method beside the number so the difference is explicable rather than alarming. Done when: Every visitor figure states its counting method where the reader will see it. Figures are not presented to a precision the method does not support. Referrer and country reporting is coarse enough not to identify anyone. A comparison against another tool's figures is explicable from the stated method rather than mysterious. Do not build yet: No goals or exports yet. ### Phase 5 · Goals, exports and staying inside the exemption Build: Goal tracking as path patterns counted in aggregate. Export of aggregates only. Assert that no third-party request, cross-site identifier or long retention exists anywhere, since the exemption conditions are cumulative. Trap: Adding one integration that sends data to a third party removes the entire exemption, because the conditions are cumulative rather than a checklist to score well on. Assert the absence of third-party requests in the suite, so an integration cannot be added without the test failing. Done when: Goals are counted in aggregate without per-visitor paths. A test fails if any third-party request is introduced anywhere in the collection or reporting path. Export contains aggregates only, with no event-level data available. The conditions the design depends on are listed in the documentation and each is asserted in the suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Anything requiring individual identification: funnels through a session, cohort retention or user-level attribution. - Legal advice about your jurisdiction. Regulators differ and the position is actively changing. - Cross-site or cross-device measurement, which is the thing the whole design refuses to do. - Competing on feature breadth with tools that do identify people, which is not a fair comparison and should not be attempted. ### README must contain - That the rule concerns storing or accessing information on a device rather than cookies specifically - That an audience measurement exemption's conditions are cumulative, so one integration removes all of them - That the daily salt is destroyed after rotation, which is what makes yesterday's hashes unrecoverable - That raw events are deleted on a short stated schedule and only aggregates survive - That visitor counts state their method, because they will be compared against another tool ===== AGENTS.md ===== # Agent instructions · Fathom Analytics indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A small script sending an event with no device storage, A rotating daily salted hash, never a persistent identifier, Counts computed and stored, raw events discarded quickly, Figures with their uncertainty stated, TypeScript with PostgreSQL. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A library that writes a single item to browser storage for deduplication reintroduces the consent trigger, and the whole reason for building this disappears for the sake of a small counting improvement. Assert in a real browser that nothing is written, rather than assuming your code does not. - Keeping the salt after rotation means the hashes remain recomputable and therefore linkable, which quietly turns a design that avoided identifiers into one that has them and simply does not use them yet. Destroy the salt, and verify it is gone rather than merely unused. - Keeping raw events indefinitely because storage is cheap turns a measurement tool into a behavioural log, which is exactly the category the exemption does not cover, and nobody notices because the interface only shows aggregates. Delete on schedule and verify the deletion runs rather than trusting it. - Presenting a visitor count as an exact figure implies a precision that a cookieless daily hash cannot provide, and site owners then compare it against another tool and conclude one is broken. State the counting method beside the number so the difference is explicable rather than alarming. ===== BUILD_PLAN.md ===== # Build plan · Fathom Analytics A web analytics service whose measurement approach is dictated by the conditions of an exemption: no storage on the device, no cross-site identity, no third-party sharing, and honest reporting of the uncertainty that those constraints introduce. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Measuring without storing anything A page view is recorded with nothing written to the visitor's device. ### Steps 1. Write nothing to the device Verified in a real browser, not by reading the code. Files: `tracker/collect.ts`, `tests/no-storage.test.ts` 2. Keep the script small and asynchronous It is on someone else's page and must not slow it down. Files: `tracker/` 3. Degrade to nothing when blocked A blocked tracker must not break a page. Files: `tracker/collect.ts` ### Done when - [ ] No storage of any kind is written in a real browser - [ ] The script size is stated and it loads asynchronously - [ ] Blocking it does not affect the site - [ ] No server-assigned identifier is used ### Watch out - A library that writes a single item to browser storage for deduplication reintroduces the consent trigger, and the whole reason for building this disappears for the sake of a small counting improvement. Assert in a real browser that nothing is written, rather than assuming your code does not. ## Phase 2 · Counting visitors without following them A returning visitor within a day counts once, and cannot be linked to yesterday. ### Steps 1. Rotate and destroy the salt Retention makes hashes recomputable, which undoes the whole design. Files: `src/salts.ts`, `tests/salt-destruction.test.ts` 2. Use coarse attributes only Fine-grained fingerprinting is identification by another name. Files: `src/visitor.ts` 3. Use the site's timezone for days A report for a European site should not roll over at a server's midnight. Files: `src/days.ts` ### Done when - [ ] A visitor within a day counts once - [ ] Yesterday's salt is destroyed, not retained - [ ] Day boundaries follow the site's timezone - [ ] The hash is not reversible ### Watch out - Keeping the salt after rotation means the hashes remain recomputable and therefore linkable, which quietly turns a design that avoided identifiers into one that has them and simply does not use them yet. Destroy the salt, and verify it is gone rather than merely unused. ## Phase 3 · Aggregating and forgetting Raw events disappear quickly and the reports survive. ### Steps 1. Roll up and delete on schedule Cheap storage is how a measurement tool becomes a behavioural log. Files: `src/rollup.ts` 2. Show the retention period To the site owner, in the interface, not in a policy document. Files: `src/settings.ts` 3. Alert on deletion failure A silently failed retention job is months of raw events. Files: `src/retention.ts` ### Done when - [ ] Raw events delete after the window and aggregates survive - [ ] The retention period is visible in the interface - [ ] Aggregates cannot reconstruct a session - [ ] Deletion failure alerts ### Watch out - Keeping raw events indefinitely because storage is cheap turns a measurement tool into a behavioural log, which is exactly the category the exemption does not cover, and nobody notices because the interface only shows aggregates. Delete on schedule and verify the deletion runs rather than trusting it. ## Phase 4 · Reporting the uncertainty honestly Figures state their limitations rather than implying precision. ### Steps 1. State the counting method beside the number Site owners compare tools and conclude one is broken otherwise. Files: `src/report.ts` 2. Round to the precision the method supports False precision is a claim you cannot defend. Files: `src/report.ts` 3. Keep dimensions coarse Country and device class, not city and model. Files: `src/dimensions.ts` ### Done when - [ ] Visitor figures state their counting method - [ ] Precision matches what the method supports - [ ] Dimensions are coarse enough not to identify - [ ] Differences from other tools are explicable ### Watch out - Presenting a visitor count as an exact figure implies a precision that a cookieless daily hash cannot provide, and site owners then compare it against another tool and conclude one is broken. State the counting method beside the number so the difference is explicable rather than alarming. ## Not in this build - Individual-level funnels, cohorts and attribution. - Legal advice about your jurisdiction. - Cross-site or cross-device measurement. - Feature parity with tools that identify people. ## After v1, if you want it - Uptime and page speed alongside traffic, which site owners want and which needs no identity - An estimate of blocked traffic, which makes the comparison against other tools explicable - Public dashboards, which are a natural fit when nothing personal is stored ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Events briefly and aggregates long term. DATABASE_URL=postgres://localhost:5432/analytics # Required. Short and stated. Cheap storage is how this becomes a behavioural log. RAW_EVENT_RETENTION_HOURS=48 # Required. After rotation the previous salt is destroyed, making old hashes unrecoverable. SALT_ROTATION_HOURS=24 # Required. Present as a constant. The exemption conditions are cumulative. THIRD_PARTY_REQUESTS=none # Required. Present as a constant. It is the thing that triggers the consent requirement. STORE_ON_DEVICE=never # Required. It runs on someone else's page. Size is a promise to them. TRACKER_MAX_BYTES=2048
You are building a lean indie version of Fathom Analytics. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Fathom Analytics · indie build A web analytics service whose measurement approach is dictated by the conditions of an exemption: no storage on the device, no cross-site identity, no third-party sharing, and honest reporting of the uncertainty that those constraints introduce. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Collection | A small script sending an event with no device storage | storing anything on the device is the thing that triggers consent | | Identity | A rotating daily salted hash, never a persistent identifier | a visitor should not be linkable across days, let alone across sites | | Aggregation | Counts computed and stored, raw events discarded quickly | short retention is a condition of the exemption and a real protection | | Reporting | Figures with their uncertainty stated | measurement without identifiers is approximate and should say so | | Runtime | TypeScript with PostgreSQL | the work is ingestion, aggregation and honest presentation | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs ingestion, aggregation and reporting. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: High-volume event ingestion and scheduled aggregation with reliable deletion. - Get it: Docker or your package manager. - Verify: psql connects and a scheduled deletion runs correctly - [ ] **Your legal position, written down** · free to read; advice worth having if this is commercial - Why: The exemption conditions become architecture in this build, and which apply to you depends on where your visitors are and which regulator you are answering to. - Get it: Read the audience measurement guidance for the jurisdictions your visitors are in and write down the conditions you are relying on. - Verify: A written note lists the conditions and each maps to a constraint in the build - [ ] **A real site with real traffic** · free - Why: Blocking rates, referrer quality and the difference between your figures and another tool's only appear against real visitors. - Get it: Your own site, running both this and an existing tool for a fortnight so the figures can be compared. - Verify: Two weeks of parallel measurement exist for comparison - [ ] **A browser you can inspect storage in** · free - Why: The central claim is that nothing is written to the device, and that is verified by looking rather than by reading the code. - Get it: Any browser with developer tools, plus a blocker installed to test the blocked case. - Verify: Storage is confirmed empty after browsing several pages ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Individual-level funnels, cohorts and attribution. - Legal advice about your jurisdiction. - Cross-site or cross-device measurement. - Feature parity with tools that identify people. - managed infrastructure - privacy/compliance messaging - email reports - uptime - support - billing If one of those is essential to you, that is the reason to keep paying for Fathom Analytics, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Fathom Analytics The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me website analytics that measures usefully without following anyone to replace Fathom Analytics. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The consent question is more subtle than not using cookies, and getting it wrong is the whole point of building this rather than using something else. The rule in the European Union is about storing or accessing information on a device rather than about cookies specifically. A tool that genuinely stores nothing on the visitor's device falls outside that trigger, but the European Data Protection Board's guidelines read it broadly enough that pixels, identifiers in URLs and reading device information can all be caught, so no cookies does not automatically mean no consent required. Separately, some regulators recognise an audience measurement exemption, and the French one is the clearest: measurement strictly for the publisher, no cross-site tracking, no sharing with third parties, anonymised addresses and limited retention. Those conditions are cumulative, so adding one third-party integration removes the whole exemption. The United Kingdom regulator has been less permissive. A proposal from November 2025 would add an EU-wide exemption for first-party aggregated measurement, but as of mid 2026 it is a proposal rather than law. So the build's constraints are the exemption's conditions, treated as architecture. Nothing on the device, nothing shared, nothing cross-site, and a retention period short enough to state. ### Stack (fixed, do not substitute) - Collection: A small script sending an event with no device storage · storing anything on the device is the thing that triggers consent - Identity: A rotating daily salted hash, never a persistent identifier · a visitor should not be linkable across days, let alone across sites - Aggregation: Counts computed and stored, raw events discarded quickly · short retention is a condition of the exemption and a real protection - Reporting: Figures with their uncertainty stated · measurement without identifiers is approximate and should say so - Runtime: TypeScript with PostgreSQL · the work is ingestion, aggregation and honest presentation ### Data model (create this before Phase 1) - sites(id, hostname, timezone, created_at, retention_days) - events(id, site_id, at, path, referrer_host, country, device_class, visitor_hash) · deleted after a short window - visitor_hash · derived from a daily rotating salt, so it cannot link across days and is not reversible - daily_rollups(site_id, date, path, views, visitors, referrer_host, country, device_class) - salts(date, value, rotated_at, destroyed_at) · destroyed after the window, which is what makes the hash unrecoverable - goals(id, site_id, name, path_pattern, created_at) and goal_rollups(goal_id, date, count) - The daily salt is destroyed after rotation, which means yesterday's hashes cannot be recomputed even by you, and that is the property the whole model rests on. ### Phase 1 · Measuring without storing anything Build: A small script sending a request with the path, referrer and screen class, storing nothing on the device. Verify no cookie, storage entry or database is written, in a real browser. Keep the script small and asynchronous. Trap: A library that writes a single item to browser storage for deduplication reintroduces the consent trigger, and the whole reason for building this disappears for the sake of a small counting improvement. Assert in a real browser that nothing is written, rather than assuming your code does not. Done when: A real browser session records views with no cookie, local storage or database entry created, verified by inspection after browsing several pages. The script is small enough to state its size and loads asynchronously. Blocking the script degrades the site not at all. Requests carry no identifier assigned by the server. Do not build yet: No visitor counting, aggregation or reporting. ### Phase 2 · Counting visitors without following them Build: Derive a visitor hash from a daily rotating salt and coarse request attributes. Destroy the salt after its window so the hash cannot be recomputed. Handle the day boundary in the site's own timezone. Trap: Keeping the salt after rotation means the hashes remain recomputable and therefore linkable, which quietly turns a design that avoided identifiers into one that has them and simply does not use them yet. Destroy the salt, and verify it is gone rather than merely unused. Done when: A visitor browsing several pages in a day counts once. The same visitor on two days is not linkable, verified by confirming the previous salt is destroyed rather than retained. Day boundaries follow the site's timezone rather than the server's. The hash cannot be reversed to an address, which is checked rather than assumed. Do not build yet: No aggregation or reporting yet. ### Phase 3 · Aggregating and forgetting Build: Roll events into daily aggregates and delete the raw events after a short stated window. Make the retention period visible to the site owner. Ensure a deletion request is meaningless because there is nothing to delete. Trap: Keeping raw events indefinitely because storage is cheap turns a measurement tool into a behavioural log, which is exactly the category the exemption does not cover, and nobody notices because the interface only shows aggregates. Delete on schedule and verify the deletion runs rather than trusting it. Done when: Raw events are deleted after the stated window and the aggregates remain, verified by advancing the clock. The retention period is visible to the site owner rather than buried. Aggregates cannot be used to reconstruct an individual's session. The deletion job runs on schedule and its failure is alerted on. Do not build yet: No reporting or goals yet. ### Phase 4 · Reporting the uncertainty honestly Build: Report views, visitors, referrers and countries with a stated note on how visitors are counted and what that misses. Do not present approximate figures to more precision than they support. Show blocked-request estimates where possible. Trap: Presenting a visitor count as an exact figure implies a precision that a cookieless daily hash cannot provide, and site owners then compare it against another tool and conclude one is broken. State the counting method beside the number so the difference is explicable rather than alarming. Done when: Every visitor figure states its counting method where the reader will see it. Figures are not presented to a precision the method does not support. Referrer and country reporting is coarse enough not to identify anyone. A comparison against another tool's figures is explicable from the stated method rather than mysterious. Do not build yet: No goals or exports yet. ### Phase 5 · Goals, exports and staying inside the exemption Build: Goal tracking as path patterns counted in aggregate. Export of aggregates only. Assert that no third-party request, cross-site identifier or long retention exists anywhere, since the exemption conditions are cumulative. Trap: Adding one integration that sends data to a third party removes the entire exemption, because the conditions are cumulative rather than a checklist to score well on. Assert the absence of third-party requests in the suite, so an integration cannot be added without the test failing. Done when: Goals are counted in aggregate without per-visitor paths. A test fails if any third-party request is introduced anywhere in the collection or reporting path. Export contains aggregates only, with no event-level data available. The conditions the design depends on are listed in the documentation and each is asserted in the suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Anything requiring individual identification: funnels through a session, cohort retention or user-level attribution. - Legal advice about your jurisdiction. Regulators differ and the position is actively changing. - Cross-site or cross-device measurement, which is the thing the whole design refuses to do. - Competing on feature breadth with tools that do identify people, which is not a fair comparison and should not be attempted. ### README must contain - That the rule concerns storing or accessing information on a device rather than cookies specifically - That an audience measurement exemption's conditions are cumulative, so one integration removes all of them - That the daily salt is destroyed after rotation, which is what makes yesterday's hashes unrecoverable - That raw events are deleted on a short stated schedule and only aggregates survive - That visitor counts state their method, because they will be compared against another tool ===== AGENTS.md ===== # Agent instructions · Fathom Analytics indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A small script sending an event with no device storage, A rotating daily salted hash, never a persistent identifier, Counts computed and stored, raw events discarded quickly, Figures with their uncertainty stated, TypeScript with PostgreSQL. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A library that writes a single item to browser storage for deduplication reintroduces the consent trigger, and the whole reason for building this disappears for the sake of a small counting improvement. Assert in a real browser that nothing is written, rather than assuming your code does not. - Keeping the salt after rotation means the hashes remain recomputable and therefore linkable, which quietly turns a design that avoided identifiers into one that has them and simply does not use them yet. Destroy the salt, and verify it is gone rather than merely unused. - Keeping raw events indefinitely because storage is cheap turns a measurement tool into a behavioural log, which is exactly the category the exemption does not cover, and nobody notices because the interface only shows aggregates. Delete on schedule and verify the deletion runs rather than trusting it. - Presenting a visitor count as an exact figure implies a precision that a cookieless daily hash cannot provide, and site owners then compare it against another tool and conclude one is broken. State the counting method beside the number so the difference is explicable rather than alarming. ===== BUILD_PLAN.md ===== # Build plan · Fathom Analytics A web analytics service whose measurement approach is dictated by the conditions of an exemption: no storage on the device, no cross-site identity, no third-party sharing, and honest reporting of the uncertainty that those constraints introduce. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Measuring without storing anything A page view is recorded with nothing written to the visitor's device. ### Steps 1. Write nothing to the device Verified in a real browser, not by reading the code. Files: `tracker/collect.ts`, `tests/no-storage.test.ts` 2. Keep the script small and asynchronous It is on someone else's page and must not slow it down. Files: `tracker/` 3. Degrade to nothing when blocked A blocked tracker must not break a page. Files: `tracker/collect.ts` ### Done when - [ ] No storage of any kind is written in a real browser - [ ] The script size is stated and it loads asynchronously - [ ] Blocking it does not affect the site - [ ] No server-assigned identifier is used ### Watch out - A library that writes a single item to browser storage for deduplication reintroduces the consent trigger, and the whole reason for building this disappears for the sake of a small counting improvement. Assert in a real browser that nothing is written, rather than assuming your code does not. ## Phase 2 · Counting visitors without following them A returning visitor within a day counts once, and cannot be linked to yesterday. ### Steps 1. Rotate and destroy the salt Retention makes hashes recomputable, which undoes the whole design. Files: `src/salts.ts`, `tests/salt-destruction.test.ts` 2. Use coarse attributes only Fine-grained fingerprinting is identification by another name. Files: `src/visitor.ts` 3. Use the site's timezone for days A report for a European site should not roll over at a server's midnight. Files: `src/days.ts` ### Done when - [ ] A visitor within a day counts once - [ ] Yesterday's salt is destroyed, not retained - [ ] Day boundaries follow the site's timezone - [ ] The hash is not reversible ### Watch out - Keeping the salt after rotation means the hashes remain recomputable and therefore linkable, which quietly turns a design that avoided identifiers into one that has them and simply does not use them yet. Destroy the salt, and verify it is gone rather than merely unused. ## Phase 3 · Aggregating and forgetting Raw events disappear quickly and the reports survive. ### Steps 1. Roll up and delete on schedule Cheap storage is how a measurement tool becomes a behavioural log. Files: `src/rollup.ts` 2. Show the retention period To the site owner, in the interface, not in a policy document. Files: `src/settings.ts` 3. Alert on deletion failure A silently failed retention job is months of raw events. Files: `src/retention.ts` ### Done when - [ ] Raw events delete after the window and aggregates survive - [ ] The retention period is visible in the interface - [ ] Aggregates cannot reconstruct a session - [ ] Deletion failure alerts ### Watch out - Keeping raw events indefinitely because storage is cheap turns a measurement tool into a behavioural log, which is exactly the category the exemption does not cover, and nobody notices because the interface only shows aggregates. Delete on schedule and verify the deletion runs rather than trusting it. ## Phase 4 · Reporting the uncertainty honestly Figures state their limitations rather than implying precision. ### Steps 1. State the counting method beside the number Site owners compare tools and conclude one is broken otherwise. Files: `src/report.ts` 2. Round to the precision the method supports False precision is a claim you cannot defend. Files: `src/report.ts` 3. Keep dimensions coarse Country and device class, not city and model. Files: `src/dimensions.ts` ### Done when - [ ] Visitor figures state their counting method - [ ] Precision matches what the method supports - [ ] Dimensions are coarse enough not to identify - [ ] Differences from other tools are explicable ### Watch out - Presenting a visitor count as an exact figure implies a precision that a cookieless daily hash cannot provide, and site owners then compare it against another tool and conclude one is broken. State the counting method beside the number so the difference is explicable rather than alarming. ## Not in this build - Individual-level funnels, cohorts and attribution. - Legal advice about your jurisdiction. - Cross-site or cross-device measurement. - Feature parity with tools that identify people. ## After v1, if you want it - Uptime and page speed alongside traffic, which site owners want and which needs no identity - An estimate of blocked traffic, which makes the comparison against other tools explicable - Public dashboards, which are a natural fit when nothing personal is stored ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Events briefly and aggregates long term. DATABASE_URL=postgres://localhost:5432/analytics # Required. Short and stated. Cheap storage is how this becomes a behavioural log. RAW_EVENT_RETENTION_HOURS=48 # Required. After rotation the previous salt is destroyed, making old hashes unrecoverable. SALT_ROTATION_HOURS=24 # Required. Present as a constant. The exemption conditions are cumulative. THIRD_PARTY_REQUESTS=none # Required. Present as a constant. It is the thing that triggers the consent requirement. STORE_ON_DEVICE=never # Required. It runs on someone else's page. Size is a promise to them. TRACKER_MAX_BYTES=2048
You are building a production product version of Fathom Analytics. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Fathom Analytics · product brief ## Problem A simple privacy analytics clone is very buildable; the paid service is hosted compliance posture, uptime, data retention, and polished reporting. ## Product outcome Analytics that writes nothing to a visitor's device, cannot link anyone across two days even if you wanted to, and states plainly how it counted what it counted. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - hosted backend - tracker script - database - domain/SSL - backups - bot filtering ## Explicit non-goals for v1 - Individual-level funnels, cohorts and attribution. - Legal advice about your jurisdiction. - Cross-site or cross-device measurement. - Feature parity with tools that identify people. - managed infrastructure - privacy/compliance messaging - email reports - uptime - support - billing ## Success criteria - No device storage is confirmed written in a real browser - Yesterday's salt is confirmed destroyed - Raw events are confirmed deleted after the window - Visitor figures are confirmed to state their method - A third-party request is confirmed to fail the suite ===== BRIEF.md ===== # Build brief · Fathom Analytics The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me website analytics that measures usefully without following anyone to replace Fathom Analytics. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The consent question is more subtle than not using cookies, and getting it wrong is the whole point of building this rather than using something else. The rule in the European Union is about storing or accessing information on a device rather than about cookies specifically. A tool that genuinely stores nothing on the visitor's device falls outside that trigger, but the European Data Protection Board's guidelines read it broadly enough that pixels, identifiers in URLs and reading device information can all be caught, so no cookies does not automatically mean no consent required. Separately, some regulators recognise an audience measurement exemption, and the French one is the clearest: measurement strictly for the publisher, no cross-site tracking, no sharing with third parties, anonymised addresses and limited retention. Those conditions are cumulative, so adding one third-party integration removes the whole exemption. The United Kingdom regulator has been less permissive. A proposal from November 2025 would add an EU-wide exemption for first-party aggregated measurement, but as of mid 2026 it is a proposal rather than law. So the build's constraints are the exemption's conditions, treated as architecture. Nothing on the device, nothing shared, nothing cross-site, and a retention period short enough to state. ### Stack (fixed, do not substitute) - Collection: A small script sending an event with no device storage · storing anything on the device is the thing that triggers consent - Identity: A rotating daily salted hash, never a persistent identifier · a visitor should not be linkable across days, let alone across sites - Aggregation: Counts computed and stored, raw events discarded quickly · short retention is a condition of the exemption and a real protection - Reporting: Figures with their uncertainty stated · measurement without identifiers is approximate and should say so - Runtime: TypeScript with PostgreSQL · the work is ingestion, aggregation and honest presentation ### Data model (create this before Phase 1) - sites(id, hostname, timezone, created_at, retention_days) - events(id, site_id, at, path, referrer_host, country, device_class, visitor_hash) · deleted after a short window - visitor_hash · derived from a daily rotating salt, so it cannot link across days and is not reversible - daily_rollups(site_id, date, path, views, visitors, referrer_host, country, device_class) - salts(date, value, rotated_at, destroyed_at) · destroyed after the window, which is what makes the hash unrecoverable - goals(id, site_id, name, path_pattern, created_at) and goal_rollups(goal_id, date, count) - The daily salt is destroyed after rotation, which means yesterday's hashes cannot be recomputed even by you, and that is the property the whole model rests on. ### Phase 1 · Measuring without storing anything Build: A small script sending a request with the path, referrer and screen class, storing nothing on the device. Verify no cookie, storage entry or database is written, in a real browser. Keep the script small and asynchronous. Trap: A library that writes a single item to browser storage for deduplication reintroduces the consent trigger, and the whole reason for building this disappears for the sake of a small counting improvement. Assert in a real browser that nothing is written, rather than assuming your code does not. Done when: A real browser session records views with no cookie, local storage or database entry created, verified by inspection after browsing several pages. The script is small enough to state its size and loads asynchronously. Blocking the script degrades the site not at all. Requests carry no identifier assigned by the server. Do not build yet: No visitor counting, aggregation or reporting. ### Phase 2 · Counting visitors without following them Build: Derive a visitor hash from a daily rotating salt and coarse request attributes. Destroy the salt after its window so the hash cannot be recomputed. Handle the day boundary in the site's own timezone. Trap: Keeping the salt after rotation means the hashes remain recomputable and therefore linkable, which quietly turns a design that avoided identifiers into one that has them and simply does not use them yet. Destroy the salt, and verify it is gone rather than merely unused. Done when: A visitor browsing several pages in a day counts once. The same visitor on two days is not linkable, verified by confirming the previous salt is destroyed rather than retained. Day boundaries follow the site's timezone rather than the server's. The hash cannot be reversed to an address, which is checked rather than assumed. Do not build yet: No aggregation or reporting yet. ### Phase 3 · Aggregating and forgetting Build: Roll events into daily aggregates and delete the raw events after a short stated window. Make the retention period visible to the site owner. Ensure a deletion request is meaningless because there is nothing to delete. Trap: Keeping raw events indefinitely because storage is cheap turns a measurement tool into a behavioural log, which is exactly the category the exemption does not cover, and nobody notices because the interface only shows aggregates. Delete on schedule and verify the deletion runs rather than trusting it. Done when: Raw events are deleted after the stated window and the aggregates remain, verified by advancing the clock. The retention period is visible to the site owner rather than buried. Aggregates cannot be used to reconstruct an individual's session. The deletion job runs on schedule and its failure is alerted on. Do not build yet: No reporting or goals yet. ### Phase 4 · Reporting the uncertainty honestly Build: Report views, visitors, referrers and countries with a stated note on how visitors are counted and what that misses. Do not present approximate figures to more precision than they support. Show blocked-request estimates where possible. Trap: Presenting a visitor count as an exact figure implies a precision that a cookieless daily hash cannot provide, and site owners then compare it against another tool and conclude one is broken. State the counting method beside the number so the difference is explicable rather than alarming. Done when: Every visitor figure states its counting method where the reader will see it. Figures are not presented to a precision the method does not support. Referrer and country reporting is coarse enough not to identify anyone. A comparison against another tool's figures is explicable from the stated method rather than mysterious. Do not build yet: No goals or exports yet. ### Phase 5 · Goals, exports and staying inside the exemption Build: Goal tracking as path patterns counted in aggregate. Export of aggregates only. Assert that no third-party request, cross-site identifier or long retention exists anywhere, since the exemption conditions are cumulative. Trap: Adding one integration that sends data to a third party removes the entire exemption, because the conditions are cumulative rather than a checklist to score well on. Assert the absence of third-party requests in the suite, so an integration cannot be added without the test failing. Done when: Goals are counted in aggregate without per-visitor paths. A test fails if any third-party request is introduced anywhere in the collection or reporting path. Export contains aggregates only, with no event-level data available. The conditions the design depends on are listed in the documentation and each is asserted in the suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Anything requiring individual identification: funnels through a session, cohort retention or user-level attribution. - Legal advice about your jurisdiction. Regulators differ and the position is actively changing. - Cross-site or cross-device measurement, which is the thing the whole design refuses to do. - Competing on feature breadth with tools that do identify people, which is not a fair comparison and should not be attempted. ### README must contain - That the rule concerns storing or accessing information on a device rather than cookies specifically - That an audience measurement exemption's conditions are cumulative, so one integration removes all of them - That the daily salt is destroyed after rotation, which is what makes yesterday's hashes unrecoverable - That raw events are deleted on a short stated schedule and only aggregates survive - That visitor counts state their method, because they will be compared against another tool ===== ARCHITECTURE.md ===== # Architecture · Fathom Analytics ## Stack | Part | Choice | Why | | --- | --- | --- | | Collection | A small script sending an event with no device storage | storing anything on the device is the thing that triggers consent | | Identity | A rotating daily salted hash, never a persistent identifier | a visitor should not be linkable across days, let alone across sites | | Aggregation | Counts computed and stored, raw events discarded quickly | short retention is a condition of the exemption and a real protection | | Reporting | Figures with their uncertainty stated | measurement without identifiers is approximate and should say so | | Runtime | TypeScript with PostgreSQL | the work is ingestion, aggregation and honest presentation | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Collection | A script that stores nothing. | Any implementation, provided device storage is verified absent in a real browser. | | Identity | Rotating salted hashes and salt destruction. | Any scheme, provided the salt is destroyed rather than retained. | | Retention | Rollup and deletion of raw events. | Any schedule, provided deletion is verified rather than assumed. | | Reporting | Figures with stated methods. | Any presentation, provided precision matches the method. | | Conditions | Asserted absence of third-party requests. | Not a module so much as a set of tests that must not be deleted. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Events briefly and aggregates long term. - `RAW_EVENT_RETENTION_HOURS` · required · Short and stated. Cheap storage is how this becomes a behavioural log. - `SALT_ROTATION_HOURS` · required · After rotation the previous salt is destroyed, making old hashes unrecoverable. - `THIRD_PARTY_REQUESTS` · required · Present as a constant. The exemption conditions are cumulative. - `STORE_ON_DEVICE` · required · Present as a constant. It is the thing that triggers the consent requirement. - `TRACKER_MAX_BYTES` · required · It runs on someone else's page. Size is a promise to them. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Fathom Analytics product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A small script sending an event with no device storage, A rotating daily salted hash, never a persistent identifier, Counts computed and stored, raw events discarded quickly, Figures with their uncertainty stated, TypeScript with PostgreSQL. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - A library that writes a single item to browser storage for deduplication reintroduces the consent trigger, and the whole reason for building this disappears for the sake of a small counting improvement. Assert in a real browser that nothing is written, rather than assuming your code does not. - Keeping the salt after rotation means the hashes remain recomputable and therefore linkable, which quietly turns a design that avoided identifiers into one that has them and simply does not use them yet. Destroy the salt, and verify it is gone rather than merely unused. - Keeping raw events indefinitely because storage is cheap turns a measurement tool into a behavioural log, which is exactly the category the exemption does not cover, and nobody notices because the interface only shows aggregates. Delete on schedule and verify the deletion runs rather than trusting it. - Presenting a visitor count as an exact figure implies a precision that a cookieless daily hash cannot provide, and site owners then compare it against another tool and conclude one is broken. State the counting method beside the number so the difference is explicable rather than alarming. - Adding one integration that sends data to a third party removes the entire exemption, because the conditions are cumulative rather than a checklist to score well on. Assert the absence of third-party requests in the suite, so an integration cannot be added without the test failing. ===== MILESTONES.md ===== # Delivery milestones · Fathom Analytics Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Measuring without storing anything A page view is recorded with nothing written to the visitor's device. ### Steps 1. Write nothing to the device Verified in a real browser, not by reading the code. Files: `tracker/collect.ts`, `tests/no-storage.test.ts` 2. Keep the script small and asynchronous It is on someone else's page and must not slow it down. Files: `tracker/` 3. Degrade to nothing when blocked A blocked tracker must not break a page. Files: `tracker/collect.ts` ### Done when - [ ] No storage of any kind is written in a real browser - [ ] The script size is stated and it loads asynchronously - [ ] Blocking it does not affect the site - [ ] No server-assigned identifier is used ### Watch out - A library that writes a single item to browser storage for deduplication reintroduces the consent trigger, and the whole reason for building this disappears for the sake of a small counting improvement. Assert in a real browser that nothing is written, rather than assuming your code does not. ## M2 · Counting visitors without following them A returning visitor within a day counts once, and cannot be linked to yesterday. ### Steps 1. Rotate and destroy the salt Retention makes hashes recomputable, which undoes the whole design. Files: `src/salts.ts`, `tests/salt-destruction.test.ts` 2. Use coarse attributes only Fine-grained fingerprinting is identification by another name. Files: `src/visitor.ts` 3. Use the site's timezone for days A report for a European site should not roll over at a server's midnight. Files: `src/days.ts` ### Done when - [ ] A visitor within a day counts once - [ ] Yesterday's salt is destroyed, not retained - [ ] Day boundaries follow the site's timezone - [ ] The hash is not reversible ### Watch out - Keeping the salt after rotation means the hashes remain recomputable and therefore linkable, which quietly turns a design that avoided identifiers into one that has them and simply does not use them yet. Destroy the salt, and verify it is gone rather than merely unused. ## M3 · Aggregating and forgetting Raw events disappear quickly and the reports survive. ### Steps 1. Roll up and delete on schedule Cheap storage is how a measurement tool becomes a behavioural log. Files: `src/rollup.ts` 2. Show the retention period To the site owner, in the interface, not in a policy document. Files: `src/settings.ts` 3. Alert on deletion failure A silently failed retention job is months of raw events. Files: `src/retention.ts` ### Done when - [ ] Raw events delete after the window and aggregates survive - [ ] The retention period is visible in the interface - [ ] Aggregates cannot reconstruct a session - [ ] Deletion failure alerts ### Watch out - Keeping raw events indefinitely because storage is cheap turns a measurement tool into a behavioural log, which is exactly the category the exemption does not cover, and nobody notices because the interface only shows aggregates. Delete on schedule and verify the deletion runs rather than trusting it. ## M4 · Reporting the uncertainty honestly Figures state their limitations rather than implying precision. ### Steps 1. State the counting method beside the number Site owners compare tools and conclude one is broken otherwise. Files: `src/report.ts` 2. Round to the precision the method supports False precision is a claim you cannot defend. Files: `src/report.ts` 3. Keep dimensions coarse Country and device class, not city and model. Files: `src/dimensions.ts` ### Done when - [ ] Visitor figures state their counting method - [ ] Precision matches what the method supports - [ ] Dimensions are coarse enough not to identify - [ ] Differences from other tools are explicable ### Watch out - Presenting a visitor count as an exact figure implies a precision that a cookieless daily hash cannot provide, and site owners then compare it against another tool and conclude one is broken. State the counting method beside the number so the difference is explicable rather than alarming. ## M5 · Goals, exports and staying inside the exemption (production only) Useful features are added without breaking the conditions the design depends on. ### Steps 1. Count goals in aggregate A per-visitor path through a site is a behavioural record. Files: `src/goals.ts` 2. Assert no third-party requests The conditions are cumulative. One integration removes all of them. Files: `tests/no-third-party.test.ts` 3. Document and test each condition So the reason for each constraint survives the person who wrote it. Files: `docs/exemption-conditions.md` ### Done when - [ ] Goals count in aggregate without visitor paths - [ ] A third-party request fails the suite - [ ] Export contains aggregates only - [ ] Each condition is documented and asserted ### Watch out - Adding one integration that sends data to a third party removes the entire exemption, because the conditions are cumulative rather than a checklist to score well on. Assert the absence of third-party requests in the suite, so an integration cannot be added without the test failing. ===== OPERATIONS.md ===== # Operations · Fathom Analytics ## Backup Aggregates are the long-lived data and are small. Raw events should generally not be backed up at all, since a backup outliving the retention window recreates exactly the data the design deletes. ## Restore Restore and confirm no raw events outside the retention window came back, and that destroyed salts stayed destroyed. A restored salt makes historic hashes linkable again. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Deletion job success and third-party request count. The first fails silently and accumulates a behavioural log; the second should always be zero and is asserted in the suite as well. ## Incident checklist Any storage appearing on a visitor's device is the failure that removes the reason for the product to exist, and it usually arrives through a dependency rather than through your own code. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No device storage is confirmed written in a real browser - [ ] Yesterday's salt is confirmed destroyed - [ ] Raw events are confirmed deleted after the window - [ ] Visitor figures are confirmed to state their method - [ ] A third-party request is confirmed to fail the suite ## Launch constraint Do not market omitted Fathom Analytics capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Events briefly and aggregates long term. DATABASE_URL=postgres://localhost:5432/analytics # Required. Short and stated. Cheap storage is how this becomes a behavioural log. RAW_EVENT_RETENTION_HOURS=48 # Required. After rotation the previous salt is destroyed, making old hashes unrecoverable. SALT_ROTATION_HOURS=24 # Required. Present as a constant. The exemption conditions are cumulative. THIRD_PARTY_REQUESTS=none # Required. Present as a constant. It is the thing that triggers the consent requirement. STORE_ON_DEVICE=never # Required. It runs on someone else's page. Size is a promise to them. TRACKER_MAX_BYTES=2048
# Fathom Analytics · indie build A web analytics service whose measurement approach is dictated by the conditions of an exemption: no storage on the device, no cross-site identity, no third-party sharing, and honest reporting of the uncertainty that those constraints introduce. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Collection | A small script sending an event with no device storage | storing anything on the device is the thing that triggers consent | | Identity | A rotating daily salted hash, never a persistent identifier | a visitor should not be linkable across days, let alone across sites | | Aggregation | Counts computed and stored, raw events discarded quickly | short retention is a condition of the exemption and a real protection | | Reporting | Figures with their uncertainty stated | measurement without identifiers is approximate and should say so | | Runtime | TypeScript with PostgreSQL | the work is ingestion, aggregation and honest presentation | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs ingestion, aggregation and reporting. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: High-volume event ingestion and scheduled aggregation with reliable deletion. - Get it: Docker or your package manager. - Verify: psql connects and a scheduled deletion runs correctly - [ ] **Your legal position, written down** · free to read; advice worth having if this is commercial - Why: The exemption conditions become architecture in this build, and which apply to you depends on where your visitors are and which regulator you are answering to. - Get it: Read the audience measurement guidance for the jurisdictions your visitors are in and write down the conditions you are relying on. - Verify: A written note lists the conditions and each maps to a constraint in the build - [ ] **A real site with real traffic** · free - Why: Blocking rates, referrer quality and the difference between your figures and another tool's only appear against real visitors. - Get it: Your own site, running both this and an existing tool for a fortnight so the figures can be compared. - Verify: Two weeks of parallel measurement exist for comparison - [ ] **A browser you can inspect storage in** · free - Why: The central claim is that nothing is written to the device, and that is verified by looking rather than by reading the code. - Get it: Any browser with developer tools, plus a blocker installed to test the blocked case. - Verify: Storage is confirmed empty after browsing several pages ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Individual-level funnels, cohorts and attribution. - Legal advice about your jurisdiction. - Cross-site or cross-device measurement. - Feature parity with tools that identify people. - managed infrastructure - privacy/compliance messaging - email reports - uptime - support - billing If one of those is essential to you, that is the reason to keep paying for Fathom Analytics, and the README should say so rather than pretend.
# Build brief · Fathom Analytics The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me website analytics that measures usefully without following anyone to replace Fathom Analytics. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The consent question is more subtle than not using cookies, and getting it wrong is the whole point of building this rather than using something else. The rule in the European Union is about storing or accessing information on a device rather than about cookies specifically. A tool that genuinely stores nothing on the visitor's device falls outside that trigger, but the European Data Protection Board's guidelines read it broadly enough that pixels, identifiers in URLs and reading device information can all be caught, so no cookies does not automatically mean no consent required. Separately, some regulators recognise an audience measurement exemption, and the French one is the clearest: measurement strictly for the publisher, no cross-site tracking, no sharing with third parties, anonymised addresses and limited retention. Those conditions are cumulative, so adding one third-party integration removes the whole exemption. The United Kingdom regulator has been less permissive. A proposal from November 2025 would add an EU-wide exemption for first-party aggregated measurement, but as of mid 2026 it is a proposal rather than law. So the build's constraints are the exemption's conditions, treated as architecture. Nothing on the device, nothing shared, nothing cross-site, and a retention period short enough to state. ### Stack (fixed, do not substitute) - Collection: A small script sending an event with no device storage · storing anything on the device is the thing that triggers consent - Identity: A rotating daily salted hash, never a persistent identifier · a visitor should not be linkable across days, let alone across sites - Aggregation: Counts computed and stored, raw events discarded quickly · short retention is a condition of the exemption and a real protection - Reporting: Figures with their uncertainty stated · measurement without identifiers is approximate and should say so - Runtime: TypeScript with PostgreSQL · the work is ingestion, aggregation and honest presentation ### Data model (create this before Phase 1) - sites(id, hostname, timezone, created_at, retention_days) - events(id, site_id, at, path, referrer_host, country, device_class, visitor_hash) · deleted after a short window - visitor_hash · derived from a daily rotating salt, so it cannot link across days and is not reversible - daily_rollups(site_id, date, path, views, visitors, referrer_host, country, device_class) - salts(date, value, rotated_at, destroyed_at) · destroyed after the window, which is what makes the hash unrecoverable - goals(id, site_id, name, path_pattern, created_at) and goal_rollups(goal_id, date, count) - The daily salt is destroyed after rotation, which means yesterday's hashes cannot be recomputed even by you, and that is the property the whole model rests on. ### Phase 1 · Measuring without storing anything Build: A small script sending a request with the path, referrer and screen class, storing nothing on the device. Verify no cookie, storage entry or database is written, in a real browser. Keep the script small and asynchronous. Trap: A library that writes a single item to browser storage for deduplication reintroduces the consent trigger, and the whole reason for building this disappears for the sake of a small counting improvement. Assert in a real browser that nothing is written, rather than assuming your code does not. Done when: A real browser session records views with no cookie, local storage or database entry created, verified by inspection after browsing several pages. The script is small enough to state its size and loads asynchronously. Blocking the script degrades the site not at all. Requests carry no identifier assigned by the server. Do not build yet: No visitor counting, aggregation or reporting. ### Phase 2 · Counting visitors without following them Build: Derive a visitor hash from a daily rotating salt and coarse request attributes. Destroy the salt after its window so the hash cannot be recomputed. Handle the day boundary in the site's own timezone. Trap: Keeping the salt after rotation means the hashes remain recomputable and therefore linkable, which quietly turns a design that avoided identifiers into one that has them and simply does not use them yet. Destroy the salt, and verify it is gone rather than merely unused. Done when: A visitor browsing several pages in a day counts once. The same visitor on two days is not linkable, verified by confirming the previous salt is destroyed rather than retained. Day boundaries follow the site's timezone rather than the server's. The hash cannot be reversed to an address, which is checked rather than assumed. Do not build yet: No aggregation or reporting yet. ### Phase 3 · Aggregating and forgetting Build: Roll events into daily aggregates and delete the raw events after a short stated window. Make the retention period visible to the site owner. Ensure a deletion request is meaningless because there is nothing to delete. Trap: Keeping raw events indefinitely because storage is cheap turns a measurement tool into a behavioural log, which is exactly the category the exemption does not cover, and nobody notices because the interface only shows aggregates. Delete on schedule and verify the deletion runs rather than trusting it. Done when: Raw events are deleted after the stated window and the aggregates remain, verified by advancing the clock. The retention period is visible to the site owner rather than buried. Aggregates cannot be used to reconstruct an individual's session. The deletion job runs on schedule and its failure is alerted on. Do not build yet: No reporting or goals yet. ### Phase 4 · Reporting the uncertainty honestly Build: Report views, visitors, referrers and countries with a stated note on how visitors are counted and what that misses. Do not present approximate figures to more precision than they support. Show blocked-request estimates where possible. Trap: Presenting a visitor count as an exact figure implies a precision that a cookieless daily hash cannot provide, and site owners then compare it against another tool and conclude one is broken. State the counting method beside the number so the difference is explicable rather than alarming. Done when: Every visitor figure states its counting method where the reader will see it. Figures are not presented to a precision the method does not support. Referrer and country reporting is coarse enough not to identify anyone. A comparison against another tool's figures is explicable from the stated method rather than mysterious. Do not build yet: No goals or exports yet. ### Phase 5 · Goals, exports and staying inside the exemption Build: Goal tracking as path patterns counted in aggregate. Export of aggregates only. Assert that no third-party request, cross-site identifier or long retention exists anywhere, since the exemption conditions are cumulative. Trap: Adding one integration that sends data to a third party removes the entire exemption, because the conditions are cumulative rather than a checklist to score well on. Assert the absence of third-party requests in the suite, so an integration cannot be added without the test failing. Done when: Goals are counted in aggregate without per-visitor paths. A test fails if any third-party request is introduced anywhere in the collection or reporting path. Export contains aggregates only, with no event-level data available. The conditions the design depends on are listed in the documentation and each is asserted in the suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Anything requiring individual identification: funnels through a session, cohort retention or user-level attribution. - Legal advice about your jurisdiction. Regulators differ and the position is actively changing. - Cross-site or cross-device measurement, which is the thing the whole design refuses to do. - Competing on feature breadth with tools that do identify people, which is not a fair comparison and should not be attempted. ### README must contain - That the rule concerns storing or accessing information on a device rather than cookies specifically - That an audience measurement exemption's conditions are cumulative, so one integration removes all of them - That the daily salt is destroyed after rotation, which is what makes yesterday's hashes unrecoverable - That raw events are deleted on a short stated schedule and only aggregates survive - That visitor counts state their method, because they will be compared against another tool
# Agent instructions · Fathom Analytics indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A small script sending an event with no device storage, A rotating daily salted hash, never a persistent identifier, Counts computed and stored, raw events discarded quickly, Figures with their uncertainty stated, TypeScript with PostgreSQL. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A library that writes a single item to browser storage for deduplication reintroduces the consent trigger, and the whole reason for building this disappears for the sake of a small counting improvement. Assert in a real browser that nothing is written, rather than assuming your code does not. - Keeping the salt after rotation means the hashes remain recomputable and therefore linkable, which quietly turns a design that avoided identifiers into one that has them and simply does not use them yet. Destroy the salt, and verify it is gone rather than merely unused. - Keeping raw events indefinitely because storage is cheap turns a measurement tool into a behavioural log, which is exactly the category the exemption does not cover, and nobody notices because the interface only shows aggregates. Delete on schedule and verify the deletion runs rather than trusting it. - Presenting a visitor count as an exact figure implies a precision that a cookieless daily hash cannot provide, and site owners then compare it against another tool and conclude one is broken. State the counting method beside the number so the difference is explicable rather than alarming.
# Build plan · Fathom Analytics A web analytics service whose measurement approach is dictated by the conditions of an exemption: no storage on the device, no cross-site identity, no third-party sharing, and honest reporting of the uncertainty that those constraints introduce. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Measuring without storing anything A page view is recorded with nothing written to the visitor's device. ### Steps 1. Write nothing to the device Verified in a real browser, not by reading the code. Files: `tracker/collect.ts`, `tests/no-storage.test.ts` 2. Keep the script small and asynchronous It is on someone else's page and must not slow it down. Files: `tracker/` 3. Degrade to nothing when blocked A blocked tracker must not break a page. Files: `tracker/collect.ts` ### Done when - [ ] No storage of any kind is written in a real browser - [ ] The script size is stated and it loads asynchronously - [ ] Blocking it does not affect the site - [ ] No server-assigned identifier is used ### Watch out - A library that writes a single item to browser storage for deduplication reintroduces the consent trigger, and the whole reason for building this disappears for the sake of a small counting improvement. Assert in a real browser that nothing is written, rather than assuming your code does not. ## Phase 2 · Counting visitors without following them A returning visitor within a day counts once, and cannot be linked to yesterday. ### Steps 1. Rotate and destroy the salt Retention makes hashes recomputable, which undoes the whole design. Files: `src/salts.ts`, `tests/salt-destruction.test.ts` 2. Use coarse attributes only Fine-grained fingerprinting is identification by another name. Files: `src/visitor.ts` 3. Use the site's timezone for days A report for a European site should not roll over at a server's midnight. Files: `src/days.ts` ### Done when - [ ] A visitor within a day counts once - [ ] Yesterday's salt is destroyed, not retained - [ ] Day boundaries follow the site's timezone - [ ] The hash is not reversible ### Watch out - Keeping the salt after rotation means the hashes remain recomputable and therefore linkable, which quietly turns a design that avoided identifiers into one that has them and simply does not use them yet. Destroy the salt, and verify it is gone rather than merely unused. ## Phase 3 · Aggregating and forgetting Raw events disappear quickly and the reports survive. ### Steps 1. Roll up and delete on schedule Cheap storage is how a measurement tool becomes a behavioural log. Files: `src/rollup.ts` 2. Show the retention period To the site owner, in the interface, not in a policy document. Files: `src/settings.ts` 3. Alert on deletion failure A silently failed retention job is months of raw events. Files: `src/retention.ts` ### Done when - [ ] Raw events delete after the window and aggregates survive - [ ] The retention period is visible in the interface - [ ] Aggregates cannot reconstruct a session - [ ] Deletion failure alerts ### Watch out - Keeping raw events indefinitely because storage is cheap turns a measurement tool into a behavioural log, which is exactly the category the exemption does not cover, and nobody notices because the interface only shows aggregates. Delete on schedule and verify the deletion runs rather than trusting it. ## Phase 4 · Reporting the uncertainty honestly Figures state their limitations rather than implying precision. ### Steps 1. State the counting method beside the number Site owners compare tools and conclude one is broken otherwise. Files: `src/report.ts` 2. Round to the precision the method supports False precision is a claim you cannot defend. Files: `src/report.ts` 3. Keep dimensions coarse Country and device class, not city and model. Files: `src/dimensions.ts` ### Done when - [ ] Visitor figures state their counting method - [ ] Precision matches what the method supports - [ ] Dimensions are coarse enough not to identify - [ ] Differences from other tools are explicable ### Watch out - Presenting a visitor count as an exact figure implies a precision that a cookieless daily hash cannot provide, and site owners then compare it against another tool and conclude one is broken. State the counting method beside the number so the difference is explicable rather than alarming. ## Not in this build - Individual-level funnels, cohorts and attribution. - Legal advice about your jurisdiction. - Cross-site or cross-device measurement. - Feature parity with tools that identify people. ## After v1, if you want it - Uptime and page speed alongside traffic, which site owners want and which needs no identity - An estimate of blocked traffic, which makes the comparison against other tools explicable - Public dashboards, which are a natural fit when nothing personal is stored
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Events briefly and aggregates long term. DATABASE_URL=postgres://localhost:5432/analytics # Required. Short and stated. Cheap storage is how this becomes a behavioural log. RAW_EVENT_RETENTION_HOURS=48 # Required. After rotation the previous salt is destroyed, making old hashes unrecoverable. SALT_ROTATION_HOURS=24 # Required. Present as a constant. The exemption conditions are cumulative. THIRD_PARTY_REQUESTS=none # Required. Present as a constant. It is the thing that triggers the consent requirement. STORE_ON_DEVICE=never # Required. It runs on someone else's page. Size is a promise to them. TRACKER_MAX_BYTES=2048
# Fathom Analytics · product brief ## Problem A simple privacy analytics clone is very buildable; the paid service is hosted compliance posture, uptime, data retention, and polished reporting. ## Product outcome Analytics that writes nothing to a visitor's device, cannot link anyone across two days even if you wanted to, and states plainly how it counted what it counted. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - hosted backend - tracker script - database - domain/SSL - backups - bot filtering ## Explicit non-goals for v1 - Individual-level funnels, cohorts and attribution. - Legal advice about your jurisdiction. - Cross-site or cross-device measurement. - Feature parity with tools that identify people. - managed infrastructure - privacy/compliance messaging - email reports - uptime - support - billing ## Success criteria - No device storage is confirmed written in a real browser - Yesterday's salt is confirmed destroyed - Raw events are confirmed deleted after the window - Visitor figures are confirmed to state their method - A third-party request is confirmed to fail the suite
# Build brief · Fathom Analytics The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me website analytics that measures usefully without following anyone to replace Fathom Analytics. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The consent question is more subtle than not using cookies, and getting it wrong is the whole point of building this rather than using something else. The rule in the European Union is about storing or accessing information on a device rather than about cookies specifically. A tool that genuinely stores nothing on the visitor's device falls outside that trigger, but the European Data Protection Board's guidelines read it broadly enough that pixels, identifiers in URLs and reading device information can all be caught, so no cookies does not automatically mean no consent required. Separately, some regulators recognise an audience measurement exemption, and the French one is the clearest: measurement strictly for the publisher, no cross-site tracking, no sharing with third parties, anonymised addresses and limited retention. Those conditions are cumulative, so adding one third-party integration removes the whole exemption. The United Kingdom regulator has been less permissive. A proposal from November 2025 would add an EU-wide exemption for first-party aggregated measurement, but as of mid 2026 it is a proposal rather than law. So the build's constraints are the exemption's conditions, treated as architecture. Nothing on the device, nothing shared, nothing cross-site, and a retention period short enough to state. ### Stack (fixed, do not substitute) - Collection: A small script sending an event with no device storage · storing anything on the device is the thing that triggers consent - Identity: A rotating daily salted hash, never a persistent identifier · a visitor should not be linkable across days, let alone across sites - Aggregation: Counts computed and stored, raw events discarded quickly · short retention is a condition of the exemption and a real protection - Reporting: Figures with their uncertainty stated · measurement without identifiers is approximate and should say so - Runtime: TypeScript with PostgreSQL · the work is ingestion, aggregation and honest presentation ### Data model (create this before Phase 1) - sites(id, hostname, timezone, created_at, retention_days) - events(id, site_id, at, path, referrer_host, country, device_class, visitor_hash) · deleted after a short window - visitor_hash · derived from a daily rotating salt, so it cannot link across days and is not reversible - daily_rollups(site_id, date, path, views, visitors, referrer_host, country, device_class) - salts(date, value, rotated_at, destroyed_at) · destroyed after the window, which is what makes the hash unrecoverable - goals(id, site_id, name, path_pattern, created_at) and goal_rollups(goal_id, date, count) - The daily salt is destroyed after rotation, which means yesterday's hashes cannot be recomputed even by you, and that is the property the whole model rests on. ### Phase 1 · Measuring without storing anything Build: A small script sending a request with the path, referrer and screen class, storing nothing on the device. Verify no cookie, storage entry or database is written, in a real browser. Keep the script small and asynchronous. Trap: A library that writes a single item to browser storage for deduplication reintroduces the consent trigger, and the whole reason for building this disappears for the sake of a small counting improvement. Assert in a real browser that nothing is written, rather than assuming your code does not. Done when: A real browser session records views with no cookie, local storage or database entry created, verified by inspection after browsing several pages. The script is small enough to state its size and loads asynchronously. Blocking the script degrades the site not at all. Requests carry no identifier assigned by the server. Do not build yet: No visitor counting, aggregation or reporting. ### Phase 2 · Counting visitors without following them Build: Derive a visitor hash from a daily rotating salt and coarse request attributes. Destroy the salt after its window so the hash cannot be recomputed. Handle the day boundary in the site's own timezone. Trap: Keeping the salt after rotation means the hashes remain recomputable and therefore linkable, which quietly turns a design that avoided identifiers into one that has them and simply does not use them yet. Destroy the salt, and verify it is gone rather than merely unused. Done when: A visitor browsing several pages in a day counts once. The same visitor on two days is not linkable, verified by confirming the previous salt is destroyed rather than retained. Day boundaries follow the site's timezone rather than the server's. The hash cannot be reversed to an address, which is checked rather than assumed. Do not build yet: No aggregation or reporting yet. ### Phase 3 · Aggregating and forgetting Build: Roll events into daily aggregates and delete the raw events after a short stated window. Make the retention period visible to the site owner. Ensure a deletion request is meaningless because there is nothing to delete. Trap: Keeping raw events indefinitely because storage is cheap turns a measurement tool into a behavioural log, which is exactly the category the exemption does not cover, and nobody notices because the interface only shows aggregates. Delete on schedule and verify the deletion runs rather than trusting it. Done when: Raw events are deleted after the stated window and the aggregates remain, verified by advancing the clock. The retention period is visible to the site owner rather than buried. Aggregates cannot be used to reconstruct an individual's session. The deletion job runs on schedule and its failure is alerted on. Do not build yet: No reporting or goals yet. ### Phase 4 · Reporting the uncertainty honestly Build: Report views, visitors, referrers and countries with a stated note on how visitors are counted and what that misses. Do not present approximate figures to more precision than they support. Show blocked-request estimates where possible. Trap: Presenting a visitor count as an exact figure implies a precision that a cookieless daily hash cannot provide, and site owners then compare it against another tool and conclude one is broken. State the counting method beside the number so the difference is explicable rather than alarming. Done when: Every visitor figure states its counting method where the reader will see it. Figures are not presented to a precision the method does not support. Referrer and country reporting is coarse enough not to identify anyone. A comparison against another tool's figures is explicable from the stated method rather than mysterious. Do not build yet: No goals or exports yet. ### Phase 5 · Goals, exports and staying inside the exemption Build: Goal tracking as path patterns counted in aggregate. Export of aggregates only. Assert that no third-party request, cross-site identifier or long retention exists anywhere, since the exemption conditions are cumulative. Trap: Adding one integration that sends data to a third party removes the entire exemption, because the conditions are cumulative rather than a checklist to score well on. Assert the absence of third-party requests in the suite, so an integration cannot be added without the test failing. Done when: Goals are counted in aggregate without per-visitor paths. A test fails if any third-party request is introduced anywhere in the collection or reporting path. Export contains aggregates only, with no event-level data available. The conditions the design depends on are listed in the documentation and each is asserted in the suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Anything requiring individual identification: funnels through a session, cohort retention or user-level attribution. - Legal advice about your jurisdiction. Regulators differ and the position is actively changing. - Cross-site or cross-device measurement, which is the thing the whole design refuses to do. - Competing on feature breadth with tools that do identify people, which is not a fair comparison and should not be attempted. ### README must contain - That the rule concerns storing or accessing information on a device rather than cookies specifically - That an audience measurement exemption's conditions are cumulative, so one integration removes all of them - That the daily salt is destroyed after rotation, which is what makes yesterday's hashes unrecoverable - That raw events are deleted on a short stated schedule and only aggregates survive - That visitor counts state their method, because they will be compared against another tool
# Architecture · Fathom Analytics ## Stack | Part | Choice | Why | | --- | --- | --- | | Collection | A small script sending an event with no device storage | storing anything on the device is the thing that triggers consent | | Identity | A rotating daily salted hash, never a persistent identifier | a visitor should not be linkable across days, let alone across sites | | Aggregation | Counts computed and stored, raw events discarded quickly | short retention is a condition of the exemption and a real protection | | Reporting | Figures with their uncertainty stated | measurement without identifiers is approximate and should say so | | Runtime | TypeScript with PostgreSQL | the work is ingestion, aggregation and honest presentation | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Collection | A script that stores nothing. | Any implementation, provided device storage is verified absent in a real browser. | | Identity | Rotating salted hashes and salt destruction. | Any scheme, provided the salt is destroyed rather than retained. | | Retention | Rollup and deletion of raw events. | Any schedule, provided deletion is verified rather than assumed. | | Reporting | Figures with stated methods. | Any presentation, provided precision matches the method. | | Conditions | Asserted absence of third-party requests. | Not a module so much as a set of tests that must not be deleted. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Events briefly and aggregates long term. - `RAW_EVENT_RETENTION_HOURS` · required · Short and stated. Cheap storage is how this becomes a behavioural log. - `SALT_ROTATION_HOURS` · required · After rotation the previous salt is destroyed, making old hashes unrecoverable. - `THIRD_PARTY_REQUESTS` · required · Present as a constant. The exemption conditions are cumulative. - `STORE_ON_DEVICE` · required · Present as a constant. It is the thing that triggers the consent requirement. - `TRACKER_MAX_BYTES` · required · It runs on someone else's page. Size is a promise to them. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Fathom Analytics product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A small script sending an event with no device storage, A rotating daily salted hash, never a persistent identifier, Counts computed and stored, raw events discarded quickly, Figures with their uncertainty stated, TypeScript with PostgreSQL. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - A library that writes a single item to browser storage for deduplication reintroduces the consent trigger, and the whole reason for building this disappears for the sake of a small counting improvement. Assert in a real browser that nothing is written, rather than assuming your code does not. - Keeping the salt after rotation means the hashes remain recomputable and therefore linkable, which quietly turns a design that avoided identifiers into one that has them and simply does not use them yet. Destroy the salt, and verify it is gone rather than merely unused. - Keeping raw events indefinitely because storage is cheap turns a measurement tool into a behavioural log, which is exactly the category the exemption does not cover, and nobody notices because the interface only shows aggregates. Delete on schedule and verify the deletion runs rather than trusting it. - Presenting a visitor count as an exact figure implies a precision that a cookieless daily hash cannot provide, and site owners then compare it against another tool and conclude one is broken. State the counting method beside the number so the difference is explicable rather than alarming. - Adding one integration that sends data to a third party removes the entire exemption, because the conditions are cumulative rather than a checklist to score well on. Assert the absence of third-party requests in the suite, so an integration cannot be added without the test failing.
# Delivery milestones · Fathom Analytics Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Measuring without storing anything A page view is recorded with nothing written to the visitor's device. ### Steps 1. Write nothing to the device Verified in a real browser, not by reading the code. Files: `tracker/collect.ts`, `tests/no-storage.test.ts` 2. Keep the script small and asynchronous It is on someone else's page and must not slow it down. Files: `tracker/` 3. Degrade to nothing when blocked A blocked tracker must not break a page. Files: `tracker/collect.ts` ### Done when - [ ] No storage of any kind is written in a real browser - [ ] The script size is stated and it loads asynchronously - [ ] Blocking it does not affect the site - [ ] No server-assigned identifier is used ### Watch out - A library that writes a single item to browser storage for deduplication reintroduces the consent trigger, and the whole reason for building this disappears for the sake of a small counting improvement. Assert in a real browser that nothing is written, rather than assuming your code does not. ## M2 · Counting visitors without following them A returning visitor within a day counts once, and cannot be linked to yesterday. ### Steps 1. Rotate and destroy the salt Retention makes hashes recomputable, which undoes the whole design. Files: `src/salts.ts`, `tests/salt-destruction.test.ts` 2. Use coarse attributes only Fine-grained fingerprinting is identification by another name. Files: `src/visitor.ts` 3. Use the site's timezone for days A report for a European site should not roll over at a server's midnight. Files: `src/days.ts` ### Done when - [ ] A visitor within a day counts once - [ ] Yesterday's salt is destroyed, not retained - [ ] Day boundaries follow the site's timezone - [ ] The hash is not reversible ### Watch out - Keeping the salt after rotation means the hashes remain recomputable and therefore linkable, which quietly turns a design that avoided identifiers into one that has them and simply does not use them yet. Destroy the salt, and verify it is gone rather than merely unused. ## M3 · Aggregating and forgetting Raw events disappear quickly and the reports survive. ### Steps 1. Roll up and delete on schedule Cheap storage is how a measurement tool becomes a behavioural log. Files: `src/rollup.ts` 2. Show the retention period To the site owner, in the interface, not in a policy document. Files: `src/settings.ts` 3. Alert on deletion failure A silently failed retention job is months of raw events. Files: `src/retention.ts` ### Done when - [ ] Raw events delete after the window and aggregates survive - [ ] The retention period is visible in the interface - [ ] Aggregates cannot reconstruct a session - [ ] Deletion failure alerts ### Watch out - Keeping raw events indefinitely because storage is cheap turns a measurement tool into a behavioural log, which is exactly the category the exemption does not cover, and nobody notices because the interface only shows aggregates. Delete on schedule and verify the deletion runs rather than trusting it. ## M4 · Reporting the uncertainty honestly Figures state their limitations rather than implying precision. ### Steps 1. State the counting method beside the number Site owners compare tools and conclude one is broken otherwise. Files: `src/report.ts` 2. Round to the precision the method supports False precision is a claim you cannot defend. Files: `src/report.ts` 3. Keep dimensions coarse Country and device class, not city and model. Files: `src/dimensions.ts` ### Done when - [ ] Visitor figures state their counting method - [ ] Precision matches what the method supports - [ ] Dimensions are coarse enough not to identify - [ ] Differences from other tools are explicable ### Watch out - Presenting a visitor count as an exact figure implies a precision that a cookieless daily hash cannot provide, and site owners then compare it against another tool and conclude one is broken. State the counting method beside the number so the difference is explicable rather than alarming. ## M5 · Goals, exports and staying inside the exemption (production only) Useful features are added without breaking the conditions the design depends on. ### Steps 1. Count goals in aggregate A per-visitor path through a site is a behavioural record. Files: `src/goals.ts` 2. Assert no third-party requests The conditions are cumulative. One integration removes all of them. Files: `tests/no-third-party.test.ts` 3. Document and test each condition So the reason for each constraint survives the person who wrote it. Files: `docs/exemption-conditions.md` ### Done when - [ ] Goals count in aggregate without visitor paths - [ ] A third-party request fails the suite - [ ] Export contains aggregates only - [ ] Each condition is documented and asserted ### Watch out - Adding one integration that sends data to a third party removes the entire exemption, because the conditions are cumulative rather than a checklist to score well on. Assert the absence of third-party requests in the suite, so an integration cannot be added without the test failing.
# Operations · Fathom Analytics ## Backup Aggregates are the long-lived data and are small. Raw events should generally not be backed up at all, since a backup outliving the retention window recreates exactly the data the design deletes. ## Restore Restore and confirm no raw events outside the retention window came back, and that destroyed salts stayed destroyed. A restored salt makes historic hashes linkable again. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Deletion job success and third-party request count. The first fails silently and accumulates a behavioural log; the second should always be zero and is asserted in the suite as well. ## Incident checklist Any storage appearing on a visitor's device is the failure that removes the reason for the product to exist, and it usually arrives through a dependency rather than through your own code. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No device storage is confirmed written in a real browser - [ ] Yesterday's salt is confirmed destroyed - [ ] Raw events are confirmed deleted after the window - [ ] Visitor figures are confirmed to state their method - [ ] A third-party request is confirmed to fail the suite ## Launch constraint Do not market omitted Fathom Analytics capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Events briefly and aggregates long term. DATABASE_URL=postgres://localhost:5432/analytics # Required. Short and stated. Cheap storage is how this becomes a behavioural log. RAW_EVENT_RETENTION_HOURS=48 # Required. After rotation the previous salt is destroyed, making old hashes unrecoverable. SALT_ROTATION_HOURS=24 # Required. Present as a constant. The exemption conditions are cumulative. THIRD_PARTY_REQUESTS=none # Required. Present as a constant. It is the thing that triggers the consent requirement. STORE_ON_DEVICE=never # Required. It runs on someone else's page. Size is a promise to them. TRACKER_MAX_BYTES=2048
$ choose a build depth, inspect the files, then open the complete pack in your agent
They pay to avoid maintaining analytics infrastructure and privacy copy themselves.
xmanaged infrastructure
xprivacy/compliance messaging
xemail reports
xuptime
xsupport
xbilling
Don't feel like building it? These folks already made it free.
all 3 free alternatives to Fathom Analytics →· no votes, no pay-to-list · just what's real
Fathom Analytics pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| 100k pageviews | $15/workspace | $12.50/workspace | Up to 100,000 monthly pageviews; up to 50 sites included |
| 200k pageviews | $25/workspace | $20.83/workspace | Up to 200,000 monthly pageviews; up to 50 sites included |
| 500k pageviews | $45/workspace | $37.50/workspace | Up to 500,000 monthly pageviews; up to 50 sites included |
| 1m pageviews | $60/workspace | $50/workspace | Up to 1,000,000 monthly pageviews; up to 50 sites included |
| 2m pageviews | $100/workspace | $83.33/workspace | Up to 2,000,000 monthly pageviews; up to 50 sites included |
| 5m pageviews | $140/workspace | $116.67/workspace | Up to 5,000,000 monthly pageviews; up to 50 sites included |
| 10m pageviews | $200/workspace | $166.67/workspace | Up to 10,000,000 monthly pageviews; up to 50 sites included |
| 15m pageviews | $290/workspace | $241.67/workspace | Up to 15,000,000 monthly pageviews; up to 50 sites included |
| 20m pageviews | $380/workspace | $316.67/workspace | Up to 20,000,000 monthly pageviews; up to 50 sites included |
| 25m pageviews | $470/workspace | $391.67/workspace | Up to 25,000,000 monthly pageviews; up to 50 sites included |
free tierno free tier
billingmonthly + annual (2 months free); 7-day trial; auto-renews until canceled
hidden costsCustom events and API calls count toward pageviews; extra sites cost an additional unpublished fee. Two consecutive months over the limit trigger an offered upgrade rather than immediate throttling.
verified 2026-08-12 · source ↗
Vibecode Fathom Analytics
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Fathom Analytics replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Fathom Analytics cost?
Fathom Analytics costs about $15/month (Standard, checked 2026-07-30), which is $180 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Fathom Analytics?
Honestly: managed infrastructure; privacy/compliance messaging; email reports; uptime; support; billing. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Fathom Analytics?
Yes: Umami (Simple privacy analytics with enough events and goals to avoid paying for simplicity.) Plausible Community Edition (The closest philosophical match, except you operate the Compose file.) GoatCounter (A smaller privacy dashboard for people who truly meant simple.) All 3 curated free alternatives are at vibecodeit.com/fathom-analytics/alternatives. The prompt is for when you want it exactly your way.