Skip to content
4 changes: 2 additions & 2 deletions analytics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions analytics/hit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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));
}
22 changes: 11 additions & 11 deletions analytics/notebooks/01_smoke_test.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
]
},
Expand All @@ -31,19 +31,19 @@
"\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\""
]
},
{
"cell_type": "markdown",
"id": "28571ecc",
"metadata": {},
"source": [
"## 1. Valid hits expect `204`"
"## 1. Valid hits \u2014 expect `204`"
]
},
{
Expand Down Expand Up @@ -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."
]
Expand Down Expand Up @@ -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)."
]
},
{
Expand Down
2 changes: 1 addition & 1 deletion analytics/notebooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions analytics/test/hit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
53 changes: 46 additions & 7 deletions scw_js/analytics-implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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`):
Expand All @@ -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.
Expand All @@ -153,12 +187,17 @@ its own before any frontend change:
confirm `counts/fretchen.eu/<hour>.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.

Expand Down
6 changes: 6 additions & 0 deletions website/pages/+onHydrationEnd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { PageContextClient } from "vike/types";
import { trackHit } from "../utils/hitTracker";

export function onHydrationEnd(pageContext: PageContextClient) {
trackHit(pageContext.urlPathname);
}
5 changes: 4 additions & 1 deletion website/pages/+onPageTransitionEnd.ts
Original file line number Diff line number Diff line change
@@ -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);
}
17 changes: 17 additions & 0 deletions website/test/hitTracker.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
7 changes: 7 additions & 0 deletions website/utils/hitTracker.ts
Original file line number Diff line number Diff line change
@@ -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 }));
}
Loading