diff --git a/AGENTS.md b/AGENTS.md index 4c4636f..c05aff2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,7 @@ pnpm build # vite build using the Cloudflare adapter - `pnpm build` verifies the Cloudflare Workers output. - Production (cryptly.dev) is served by Vercel: `frontend/vercel.json` builds `apps/web` with `adapter-vercel` (selected when `VERCEL=1`). - Public env is inlined at build time; production values live in `apps/web/.env.production`. +- Analytics: Logdash, served first-party by `apps/web/src/routes/_ld`. Use `track` / `identify` from `$lib/analytics/logdash`. Never put emails, names, user IDs or other personal data in event names or props; add new dynamic routes to `PATH_TEMPLATES` in `_ld/events/+server.ts`. ### Backend SDK (`packages/backend-sdk/`) diff --git a/CLAUDE.md b/CLAUDE.md index 56e10c8..87ec3c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,7 @@ pnpm build # vite build using the Cloudflare adapter - `pnpm build` verifies the Cloudflare Workers output. - Production (cryptly.dev) is served by Vercel: `frontend/vercel.json` builds `apps/web` with `adapter-vercel` (selected when `VERCEL=1`). - Public env is inlined at build time; production values live in `apps/web/.env.production`. +- Analytics: Logdash, served first-party by `apps/web/src/routes/_ld`. Use `track` / `identify` from `$lib/analytics/logdash`. Never put emails, names, user IDs or other personal data in event names or props; add new dynamic routes to `PATH_TEMPLATES` in `_ld/events/+server.ts`. ### Backend SDK (`packages/backend-sdk/`) diff --git a/apps/web/e2e/logdash-proxy.spec.ts b/apps/web/e2e/logdash-proxy.spec.ts new file mode 100644 index 0000000..e4b1563 --- /dev/null +++ b/apps/web/e2e/logdash-proxy.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from "@playwright/test"; + +// Requests the /_ld proxy must reject before anything reaches Logdash. +const APP = process.env.SVELTE_URL ?? "http://127.0.0.1:9090"; +const EVENTS = `${APP}/_ld/events`; +const SITE_ID = "6acbf37df07d59985c0f7a20"; + +const event = { name: "pageview", path: "/" }; +const json = { "content-type": "application/json" }; + +test("serves the tracker script as JavaScript", async ({ request }) => { + const res = await request.get(`${APP}/_ld/script.js`); + expect(res.status()).toBe(200); + expect(res.headers()["content-type"]).toContain("application/javascript"); + expect(res.headers()["cache-control"]).toContain("public"); +}); + +test("rejects non-JSON bodies", async ({ request }) => { + const res = await request.post(EVENTS, { + headers: { "content-type": "text/plain" }, + data: JSON.stringify({ siteId: SITE_ID, events: [event] }), + }); + expect(res.status()).toBe(415); +}); + +test("rejects bodies over 32 KB", async ({ request }) => { + const res = await request.post(EVENTS, { + headers: json, + data: JSON.stringify({ + siteId: SITE_ID, + events: [{ ...event, pad: "x".repeat(33 * 1024) }], + }), + }); + expect(res.status()).toBe(413); +}); + +test("rejects batches over 20 events", async ({ request }) => { + const res = await request.post(EVENTS, { + headers: json, + data: JSON.stringify({ + siteId: SITE_ID, + events: Array.from({ length: 21 }, () => event), + }), + }); + expect(res.status()).toBe(413); +}); + +test("rejects other site IDs", async ({ request }) => { + const res = await request.post(EVENTS, { + headers: json, + data: JSON.stringify({ siteId: "0".repeat(24), events: [event] }), + }); + expect(res.status()).toBe(403); +}); + +test("rejects malformed JSON and empty batches", async ({ request }) => { + const malformed = await request.post(EVENTS, { headers: json, data: "{" }); + expect(malformed.status()).toBe(400); + const empty = await request.post(EVENTS, { + headers: json, + data: JSON.stringify({ siteId: SITE_ID, events: [] }), + }); + expect(empty.status()).toBe(400); + expect(empty.headers()["cache-control"]).toBe("no-store"); +}); diff --git a/apps/web/src/app.html b/apps/web/src/app.html index 40a3933..4429049 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -41,6 +41,14 @@ content="https://i.ibb.co/gbGXNw6J/cryptly-og.png" /> + + + %sveltekit.head% ; + +interface Logdash { + track(name: string, props?: EventProps): void; + identify(userId: string | null): void; +} + +declare global { + interface Window { + logdash?: Logdash; + } +} + +/** Runs now if the deferred tracker is ready, otherwise once its script has loaded. */ +function whenReady(fn: (logdash: Logdash) => void): void { + if (!browser) return; + if (window.logdash) { + fn(window.logdash); + return; + } + document.querySelector('script[src="/_ld/script.js"]')?.addEventListener( + "load", + () => { + if (window.logdash) fn(window.logdash); + }, + { once: true }, + ); +} + +/** Opaque account ID only; the tracker hashes it in the browser. `null` on sign-out. */ +export function identify(userId: string | null): void { + whenReady((logdash) => logdash.identify(userId)); +} + +export function track(name: string, props?: EventProps): void { + whenReady((logdash) => logdash.track(name, props)); +} diff --git a/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte b/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte index 57f4935..e26b164 100644 --- a/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte +++ b/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte @@ -5,6 +5,7 @@ import { Check, CornerDownLeft, Terminal } from "lucide-svelte"; import { onMount, tick } from "svelte"; import { animate } from "motion"; + import { track } from "$lib/analytics/logdash"; import { ApiResponseError } from "$lib/api/backend"; import { AsymmetricCrypto } from "$lib/auth/asymmetric-crypto"; import { @@ -158,6 +159,7 @@ wrappedKey, encryptedPrivateKey, }); + track("cli_authorized"); approveState = "done"; } catch (e: unknown) { diff --git a/apps/web/src/lib/auth/ui/LocalLoginForm.svelte b/apps/web/src/lib/auth/ui/LocalLoginForm.svelte index 4d36ba9..d160a6f 100644 --- a/apps/web/src/lib/auth/ui/LocalLoginForm.svelte +++ b/apps/web/src/lib/auth/ui/LocalLoginForm.svelte @@ -1,6 +1,7 @@