diff --git a/analytics/README.md b/analytics/README.md index 70d85b5d9..c2ac2cc4e 100644 --- a/analytics/README.md +++ b/analytics/README.md @@ -67,8 +67,8 @@ npm run build # tsup → dist/ ### Local server ```bash -npm run dev # localhost:8085, file storage (notebooks/state/) — no credentials needed -npm run dev:live # localhost:8085, real S3 (needs analytics/.env) — pre-deploy sanity check +npm run dev # localhost:8086, file storage (notebooks/state/) — no credentials needed +npm run dev:live # localhost:8086, real S3 (needs analytics/.env) — pre-deploy sanity check ``` `npm run dev` writes counters to `notebooks/state/` instead of S3 diff --git a/analytics/hit.ts b/analytics/hit.ts index 920cc2ef1..c0c06aa29 100644 --- a/analytics/hit.ts +++ b/analytics/hit.ts @@ -21,7 +21,7 @@ const MAX_PATH_LENGTH = 200; const MAX_PAGES_PER_BUCKET = 200; // caps distinct paths tracked per hour bucket const MAX_CAS_ATTEMPTS = 3; -const ALLOWED_ORIGINS = ["https://www.fretchen.eu", "http://localhost:3000"]; +const ALLOWED_ORIGINS = ["https://www.fretchen.eu", "http://localhost:5173"]; // Local dev/sandbox (`npm run dev`) uses a file store with no credentials; // production and `npm test` (both leave ANALYTICS_STORAGE unset) use real S3. @@ -157,6 +157,6 @@ if (isEntrypoint && process.env.NODE_ENV === "test") { // structurally assignable to the package's own looser Event type — same // cast scw_js/llm_x402_cron.ts uses for the identical mismatch. // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument - scw.serveHandler(handle as any, 8085); + scw.serveHandler(handle as any, 8086); })().catch((err) => console.error("Error starting local server", err)); } diff --git a/analytics/notebooks/01_smoke_test.ipynb b/analytics/notebooks/01_smoke_test.ipynb index c114bf748..565d5527f 100644 --- a/analytics/notebooks/01_smoke_test.ipynb +++ b/analytics/notebooks/01_smoke_test.ipynb @@ -8,11 +8,11 @@ "# Smoke test: `analytics` hit-counter service\n", "\n", "Two targets, toggled by `LOCAL` below:\n", - "- **`LOCAL = True`** — `npm run dev` running in another terminal (file storage,\n", - " no credentials needed at all — `localhost:8085`).\n", - "- **`LOCAL = False`** — the deployed `https://analytics.fretchen.eu`, real S3\n", + "- **`LOCAL = True`** \u2014 `npm run dev` running in another terminal (file storage,\n", + " no credentials needed at all \u2014 `localhost:8086`).\n", + "- **`LOCAL = False`** \u2014 the deployed Scaleway function, real S3\n", " (needs `SCW_ACCESS_KEY`/`SCW_SECRET_KEY` in `analytics/.env`, copied from\n", - " `analytics/.env.example`, only for the read-back step — the `POST /hit` call\n", + " `analytics/.env.example`, only for the read-back step \u2014 the `POST /hit` call\n", " itself needs no credentials either way)." ] }, @@ -31,11 +31,11 @@ "\n", "from storage import LocalStorage, S3Storage\n", "\n", - "load_dotenv() # searches upward — finds ../.env (analytics/.env)\n", + "load_dotenv() # searches upward \u2014 finds ../.env (analytics/.env)\n", "\n", "LOCAL = False # npm run dev (file storage) vs the deployed service\n", "\n", - "ANALYTICS_URL = \"http://localhost:8085\" if LOCAL else \"https://analyticsserviceebp8thpt-hit.functions.fnc.fr-par.scw.cloud\"" + "ANALYTICS_URL = \"http://localhost:8086\" if LOCAL else \"https://analyticsserviceebp8thpt-hit.functions.fnc.fr-par.scw.cloud\"" ] }, { @@ -43,7 +43,7 @@ "id": "28571ecc", "metadata": {}, "source": [ - "## 1. Valid hits — expect `204`" + "## 1. Valid hits \u2014 expect `204`" ] }, { @@ -75,7 +75,7 @@ "id": "0e783179", "metadata": {}, "source": [ - "## 2. Invalid site — expect `400`\n", + "## 2. Invalid site \u2014 expect `400`\n", "\n", "Confirms `hit.ts`'s `ALLOWED_SITE` validation is actually live on the target." ] @@ -110,10 +110,10 @@ "## 3. Read the write back\n", "\n", "Local mode reads the same `notebooks/state/` directory `npm run dev`'s\n", - "`FileHitStorage` writes to (`analytics/storage.ts`) — no new plumbing, just the\n", + "`FileHitStorage` writes to (`analytics/storage.ts`) \u2014 no new plumbing, just the\n", "matching `LocalStorage` class. Live mode reads real S3, confirming the counter is\n", - "really there — and that it's **not** publicly readable without these\n", - "credentials (see `analytics/README.md` — counters are private by design)." + "really there \u2014 and that it's **not** publicly readable without these\n", + "credentials (see `analytics/README.md` \u2014 counters are private by design)." ] }, { diff --git a/analytics/notebooks/README.md b/analytics/notebooks/README.md index 75355f616..277933786 100644 --- a/analytics/notebooks/README.md +++ b/analytics/notebooks/README.md @@ -26,7 +26,7 @@ wrote, no extra wiring. in another terminal: ```bash -cd .. && npm run dev # localhost:8085, file storage, no credentials +cd .. && npm run dev # localhost:8086, file storage, no credentials ``` ## Setup diff --git a/analytics/test/hit.test.ts b/analytics/test/hit.test.ts index 1e1993a27..491eeba7d 100644 --- a/analytics/test/hit.test.ts +++ b/analytics/test/hit.test.ts @@ -42,10 +42,10 @@ describe("hit handler", () => { it("echoes back a whitelisted origin", async () => { const res = await handle( - makeEvent({ httpMethod: "OPTIONS", body: undefined, headers: { origin: "http://localhost:3000" } }), + makeEvent({ httpMethod: "OPTIONS", body: undefined, headers: { origin: "http://localhost:5173" } }), {}, ); - expect(res.headers["Access-Control-Allow-Origin"]).toBe("http://localhost:3000"); + expect(res.headers["Access-Control-Allow-Origin"]).toBe("http://localhost:5173"); }); it("falls back to the canonical origin for an unknown origin", async () => { diff --git a/scw_js/analytics-implementation-plan.md b/scw_js/analytics-implementation-plan.md index 9b36e98a3..972752f49 100644 --- a/scw_js/analytics-implementation-plan.md +++ b/scw_js/analytics-implementation-plan.md @@ -67,12 +67,35 @@ Scaffold copied from `comment_service/` (closest existing analog: anonymous, unauthenticated, S3-backed POST endpoint, own folder, one function): - `package.json` — only dependency: `@fretchen/s3-utils` (`file:../shared/s3-utils`). -- `serverless.yml` — one function, `custom_domains: analytics.fretchen.eu`, - `secret:` block with just `SCW_ACCESS_KEY`/`SCW_SECRET_KEY`. +- `serverless.yml` — one function, `secret:` block with just + `SCW_ACCESS_KEY`/`SCW_SECRET_KEY`. **No custom domain for now** — deploys + to Scaleway's auto-generated function URL + (`https://analyticsserviceebp8thpt-hit.functions.fnc.fr-par.scw.cloud`); + `custom_domains: [analytics.fretchen.eu]` can be added back once DNS/cert + is set up (see the comment in `serverless.yml`). - `tsup.config.js`, `vitest.config.js`, `eslint.config.js`, `tsconfig.json` — - copy from `comment_service/`. + copy from `comment_service/`. `tsup.config.js` needed a follow-up fix: + `noExternal: [/.*/]` bundled the local-dev-only `dotenv`/ + `@scaleway/serverless-functions` (and its Fastify dependency) into the + production output — 1.77MB for a 10.91KB function. Fixed by narrowing + `noExternal` to just `["@fretchen/s3-utils"]` and adding the two dev-only + packages to `external` explicitly. `serverless.yml`'s `package.patterns` + needed the same fix `scw_js/serverless.yml` already required: without a + leading `"!**"` it isn't a real allowlist, so once `analytics/notebooks/` + existed (its own 280MB+ `.venv/`) the deploy zip ballooned to ~137MB. - `README.md` — one paragraph, matching `comment_service/README.md`'s shape. Add a row for `analytics/` to the root `CLAUDE.md` directory table. +- `storage.ts` — a `HitStorage` interface behind `hit.ts`'s calls, not + `@fretchen/s3-utils` called directly. `S3HitStorage` (production) wraps + `getS3ObjectWithMeta`/`putS3ObjectConditional`; `FileHitStorage` (local + dev, `ANALYTICS_STORAGE=file`) is a JSON-file store with an MD5-based + ETag, sharing `analytics/notebooks/`'s `state/` directory with the Python + `LocalStorage` class there. This exists to support `npm run dev`/ + `npm run dev:live` — a local server (`@scaleway/serverless-functions`' + `serveHandler`, port `8086`, same pattern as `scw_js/growth_api.ts`'s dev + bootstrap) added after the initial PR1 build, so the endpoint can be + exercised without live credentials. See `analytics/README.md`'s "Local + server" section. `hit.ts` handler — request/response shape and CORS modeled on `comment_service/comments.ts` (`ScalewayEvent`, `HandlerResponse`, @@ -110,13 +133,24 @@ malformed body, invalid/oversized `path` rejection, and the `pages` cap. ```ts // website/utils/hitTracker.ts -const ANALYTICS_URL = import.meta.env.PUBLIC_ENV__ANALYTICS_URL ?? "https://analytics.fretchen.eu"; +const ANALYTICS_URL = + import.meta.env.PUBLIC_ENV__ANALYTICS_URL ?? + "https://analyticsserviceebp8thpt-hit.functions.fnc.fr-par.scw.cloud"; export function trackHit(path: string) { navigator.sendBeacon(`${ANALYTICS_URL}/hit`, JSON.stringify({ site: "fretchen.eu", path })); } ``` +The fallback is the Scaleway auto-generated URL, not a custom domain (see +above) — and it matters that this is the _fallback_, not just a local-dev +default: `.github/workflows/pages.yml` (the site's only build/deploy +workflow) never sets any `PUBLIC_ENV__*` variable, so whatever ships here is +unconditionally what production uses. `PUBLIC_ENV__ANALYTICS_URL` is a +local-dev-only override (e.g. pointing at `npm run dev`'s `localhost:8086`). +When `analytics.fretchen.eu` is reattached, this fallback string needs a +follow-up edit — there's no CI mechanism to swap it automatically. + ### 3. Wiring In `website/layouts/LayoutDefault.tsx` (already imports `usePageContext`): @@ -143,7 +177,7 @@ events, when added, are read through that same endpoint. ## PRs: two **PR 1 — `analytics/` package.** Backend only, deployable and testable on -its own before any frontend change: +its own before any frontend change. ✅ Done, deployed: 1. Scaffold `analytics/` from `comment_service/`; write `hit.ts` using the CAS loop from `scw_js/x402_channel_storage.ts` as a template. @@ -153,12 +187,17 @@ its own before any frontend change: confirm `counts/fretchen.eu/.json` appears/increments in the bucket, including a second write in the same hour (exercises the `ifMatch` path, not just `ifNoneMatch`). +5. _(added after the initial build)_ Fix the two build/deploy bugs above + (bundle size, deploy zip size); add `storage.ts` + `npm run dev`/ + `dev:live` for local testing without live credentials; add + `analytics/notebooks/` (Python, `requests`-based smoke test + a readout + prototype) for manual verification. **PR 2 — `website/` wiring.** Umami stays untouched and running in parallel: 1. Add `website/utils/hitTracker.ts` and wire it into `LayoutDefault.tsx`. -2. Verify in the browser (Network tab): a `sendBeacon` fires to - `analytics.fretchen.eu/hit` on load and again on client-side navigation. +2. Verify in the browser (Network tab): a `sendBeacon` fires to the + deployed analytics URL on load and again on client-side navigation. 3. Leave Umami running unchanged — remove it in a later, separate change once a few days of real counts look sane. diff --git a/website/pages/+onHydrationEnd.ts b/website/pages/+onHydrationEnd.ts new file mode 100644 index 000000000..145ed5e4e --- /dev/null +++ b/website/pages/+onHydrationEnd.ts @@ -0,0 +1,6 @@ +import type { PageContextClient } from "vike/types"; +import { trackHit } from "../utils/hitTracker"; + +export function onHydrationEnd(pageContext: PageContextClient) { + trackHit(pageContext.urlPathname); +} diff --git a/website/pages/+onPageTransitionEnd.ts b/website/pages/+onPageTransitionEnd.ts index 2f671c3a7..f8ddfc664 100644 --- a/website/pages/+onPageTransitionEnd.ts +++ b/website/pages/+onPageTransitionEnd.ts @@ -1,4 +1,7 @@ import type { PageContext } from "vike/types"; -export function onPageTransitionEnd(_pageContext: PageContext) { +import { trackHit } from "../utils/hitTracker"; + +export function onPageTransitionEnd(pageContext: PageContext) { document.querySelector("body")?.classList.remove("page-is-transitioning"); + trackHit(pageContext.urlPathname); } diff --git a/website/test/hitTracker.test.ts b/website/test/hitTracker.test.ts new file mode 100644 index 000000000..85c6a6102 --- /dev/null +++ b/website/test/hitTracker.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { trackHit } from "@utils/hitTracker"; + +describe("trackHit", () => { + beforeEach(() => { + vi.stubGlobal("navigator", { sendBeacon: vi.fn() }); + }); + + it("sends a beacon to /hit with the site and path", () => { + trackHit("/blog/foo"); + + expect(navigator.sendBeacon).toHaveBeenCalledTimes(1); + const [url, body] = vi.mocked(navigator.sendBeacon).mock.calls[0]; + expect(url).toMatch(/\/hit$/); + expect(JSON.parse(body as string)).toEqual({ site: "fretchen.eu", path: "/blog/foo" }); + }); +}); diff --git a/website/utils/hitTracker.ts b/website/utils/hitTracker.ts new file mode 100644 index 000000000..0ec0e37e8 --- /dev/null +++ b/website/utils/hitTracker.ts @@ -0,0 +1,7 @@ +const ANALYTICS_URL = + (import.meta.env.PUBLIC_ENV__ANALYTICS_URL as string | undefined) ?? + "https://analyticsserviceebp8thpt-hit.functions.fnc.fr-par.scw.cloud"; + +export function trackHit(path: string) { + navigator.sendBeacon(`${ANALYTICS_URL}/hit`, JSON.stringify({ site: "fretchen.eu", path })); +}