diff --git a/apps/web/src/lib/server/metrics/metrics.test.ts b/apps/web/src/lib/server/metrics/metrics.test.ts index 3fa6005..cfa9cb6 100644 --- a/apps/web/src/lib/server/metrics/metrics.test.ts +++ b/apps/web/src/lib/server/metrics/metrics.test.ts @@ -302,6 +302,21 @@ describe('siteMetrics', () => { expect(empty.byDay).toEqual([]); }); + it('never calls fetch with the api object as `this` — workerd rejects that', async () => { + const thisValues: unknown[] = []; + const api = { + accountId: 'acct', + token: 'tok', + fetch: function (this: unknown) { + thisValues.push(this); + return Promise.resolve(new Response(JSON.stringify({ data: [] }))); + } as typeof fetch + }; + await siteMetrics(api, DID, 7); + expect(thisValues.length).toBeGreaterThan(0); + for (const t of thisValues) expect(t).toBe(globalThis); + }); + it('surfaces an API failure with the status', async () => { const api = { accountId: 'acct', diff --git a/apps/web/src/lib/server/metrics/query.ts b/apps/web/src/lib/server/metrics/query.ts index 5b022ef..d1b68ff 100644 --- a/apps/web/src/lib/server/metrics/query.ts +++ b/apps/web/src/lib/server/metrics/query.ts @@ -49,7 +49,9 @@ export function didLiteral(did: string): string { } async function sql(api: MetricsApi, query: string): Promise[]> { - const res = await api.fetch( + // fetch rejects a foreign `this` (the api object a method call would pass). + const res = await api.fetch.call( + globalThis, `https://api.cloudflare.com/client/v4/accounts/${api.accountId}/analytics_engine/sql`, { method: 'POST', -- 2.51.2 From bd36da9e7f3777000719a3748fe9f3e132e8c38e Mon Sep 17 00:00:00 2001 From: Jacob Zweifel Date: Thu, 27 Aug 2026 21:42:20 -0400 Subject: [PATCH 2/2] NEXT: #45 is deployed; the dashboard fix is PR #46 --- docs/NEXT.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/NEXT.md b/docs/NEXT.md index c07ad3c..889f86e 100644 --- a/docs/NEXT.md +++ b/docs/NEXT.md @@ -2,7 +2,7 @@ The flight plan. Each item carries enough context to start cold; update this file whenever an item lands (move it to "Done") or a new one is queued. Decisions made while working an item still go through `decisions/` as usual. -_Last updated: 2026-08-27 (**PD-11 recorded: the site-metrics offering** — free real-7d / paid-30d on Analytics Engine, design fully settled in `research/2026-08-26-analytics-offering.md`, **and now built** — ADR 0016 Proposed, PR #45 open, deploy pending; see item 2 and Done. Also: **PRs #27, #28 and #31 are all merged and deployed.** mooring.page now serves the demo-first landing page (PD-10) — verified live: the apex carries the Open Graph card, `/s/jzweifel.dev` renders with the claim banner and `noindex`, and the domain lock, sign-off band and republished `signOff` lexicon from the earlier PRs are all in place. **PR #33 is merged and deployed too** — the postmark cut-off fix and the landing footer's attribution row (both below), smoke-tested live. **PR #35 is merged and deployed** — the admin and login pages wear the site letterhead (details below). **PR #37 is merged and deployed too** — it fixes the white frame #35 shipped with (the `body { margin: 0 }` reset lived only in SiteLayout's `:global` styles, so routes that never bundle SiteLayout kept the default body margin; `letterhead.css` now resets body margin and paints `html` with background + `color-scheme` via `:has(.letterhead)`, mirroring SiteLayout's own root treatment) — verified live: the served letterhead stylesheet on mooring.page/login carries the reset in both schemes. **PR #39 is merged and deployed too** — the theme's contrast guards for user color overrides (details below); verified live in both schemes. **PR #41 is merged and deployed too** — Bluesky post embeds render instead of the cue (details below); verified live. **PR #43 is open and not yet merged** — trimmed posts/writing sections now close with a cue carrying the count and a link to where the rest lives, and the career log stays whole by decision (details below); this closes the *volume behavior* sub-item of the theme tail. **#43 is in flight; the rest of the queue below is current.** The queue below is otherwise current. Critique trends, two separate targets: the rendered **theme** ran 25 → 31 → 29 → 32, and the **landing page** has one run at 21/40 — snapshots in `.impeccable/critique/`)._ +_Last updated: 2026-08-27 (**PD-11 recorded: the site-metrics offering** — free real-7d / paid-30d on Analytics Engine, design fully settled in `research/2026-08-26-analytics-offering.md`, **and now built** — ADR 0016 Proposed, **#45 merged and deployed 2026-08-27**; the first live dashboard read exposed a workerd-only fetch-receiver bug, fix in **PR #46 (open)**; see item 2 and Done. Also: **PRs #27, #28 and #31 are all merged and deployed.** mooring.page now serves the demo-first landing page (PD-10) — verified live: the apex carries the Open Graph card, `/s/jzweifel.dev` renders with the claim banner and `noindex`, and the domain lock, sign-off band and republished `signOff` lexicon from the earlier PRs are all in place. **PR #33 is merged and deployed too** — the postmark cut-off fix and the landing footer's attribution row (both below), smoke-tested live. **PR #35 is merged and deployed** — the admin and login pages wear the site letterhead (details below). **PR #37 is merged and deployed too** — it fixes the white frame #35 shipped with (the `body { margin: 0 }` reset lived only in SiteLayout's `:global` styles, so routes that never bundle SiteLayout kept the default body margin; `letterhead.css` now resets body margin and paints `html` with background + `color-scheme` via `:has(.letterhead)`, mirroring SiteLayout's own root treatment) — verified live: the served letterhead stylesheet on mooring.page/login carries the reset in both schemes. **PR #39 is merged and deployed too** — the theme's contrast guards for user color overrides (details below); verified live in both schemes. **PR #41 is merged and deployed too** — Bluesky post embeds render instead of the cue (details below); verified live. **PR #43 is open and not yet merged** — trimmed posts/writing sections now close with a cue carrying the count and a link to where the rest lives, and the career log stays whole by decision (details below); this closes the *volume behavior* sub-item of the theme tail. **#43 is in flight; the rest of the queue below is current.** The queue below is otherwise current. Critique trends, two separate targets: the rendered **theme** ran 25 → 31 → 29 → 32, and the **landing page** has one run at 21/40 — snapshots in `.impeccable/critique/`)._ ## Where things stand @@ -22,9 +22,9 @@ Done so far: OAuth login (loopback dev client; hosted-client path ready pending - **Landing-page critique follow-ups, design-decision tier** (queued 2026-08-25; snapshot `.impeccable/critique/2026-08-26T01-52-10Z__apps-web-src-routes-page-svelte.md`, scored 21/40 — the three P1s and the mechanical P2s are fixed; what remains needs product calls): gloss or drop "standard.site"/"sifa" jargon for the non-technical launch audience; a pricing signal ("free subdomain" reads as an unpriced paywall); accept a pasted DID in the lookup (currently lowercased and rejected as a typo); distinguish resolver outages from typos in the error copy; and the big swing — show a real rendered site on the landing page instead of describing one ("you've built a rendering engine and put a text ad in front of it"). - **Explore extracting themeability, and a second theme — possibly nautical** (queued 2026-08-25, Jacob's hunch): "Mooring" reads as boats to plenty of people before it reads as airships, and the professional-presence theme is currently the only theme, its palette and motifs woven through `SiteLayout`/`SiteSections` rather than sitting behind a seam. Two questions to explore together: (1) what a theme contract would look like (tokens? component set? the `theme.colors` override mechanism already hints at one) and whether extracting it is worth the indirection while there is exactly one theme; (2) whether a harbor/nautical variant (same letter-and-postmark bones, different motif and palette) is a cheap second theme that meets boat-minded visitors where they land. Mock on the design canvas before building; ADR 0008 scope discipline applies — this is exploration, not a committed v1 item. -### 2. Site metrics — free 7d dashboard on Analytics Engine (PD-11; **built 2026-08-27, PR #45 open — see Done**; deploy + live verification remain) +### 2. Site metrics — free 7d dashboard on Analytics Engine (PD-11; **#45 merged and deployed 2026-08-27; fix PR #46 open** — live verification blocked on it) -The build landed per the settled design (`research/2026-08-26-analytics-offering.md`) and ADR 0016 (Proposed — ratify on the PR). What remains, in order: (1) **deploy steps** — create an API token scoped Account Analytics:Read and `wrangler secret put ANALYTICS_READ_TOKEN` (the `CLOUDFLARE_ACCOUNT_ID` var is already in wrangler.jsonc; until the secret is set the dashboard names the gap and ingest records regardless), then merge + `npm run deploy -w web`; (2) **smoke-test live** — view a site, then read `/admin/analytics` signed in as the site's owner, and as the `mooring.page` authority account for the apex/`/s/` funnel; ingest-to-readable lag is expected to be minutes; (3) the **30d unlock** still waits on billing — data written from deploy day makes it retroactive. +Jacob merged #45, deployed (Worker version `5102460b`), and set `ANALYTICS_READ_TOKEN`. The first live dashboard read then threw — the workerd fetch-receiver bug, see Done — fixed in **PR #46**. What remains: merge #46, `npm run deploy -w web`, then **smoke-test live**: read `/admin/analytics` signed in as a site owner (real browser views were planted 2026-08-28 ~01:40 UTC across all three host classes: apex + `/s/jzweifel.dev` under the authority DID, `malpercio.mooring.page` + `/about` under `did:web:malpercio.dev`, `jzweifel.dev` under Jacob's DID), and as the `mooring.page` authority account for the apex/`/s/` funnel; ingest-to-readable lag is minutes. The **30d unlock** still waits on billing — data written since deploy makes it retroactive. ADR 0016 rode in #45; mark it Accepted once Jacob confirms the ratification. ## Standing / background @@ -36,7 +36,7 @@ The build landed per the settled design (`research/2026-08-26-analytics-offering ## Done -- 2026-08-27 — **Site metrics built end to end** (PD-11's build session; PR #45, **open, not merged or deployed**). ADR 0016 (Proposed) fixes the specifics: dataset `mooring_site_views` bound as `METRICS`, one data point per rendered HTML page view (GET · 200 · `text/html`) written from `hooks.server.ts` under `waitUntil` — the one place every tenant host has already resolved to a DID — with `index1` = site DID and blobs path / referrer host / country / daily visitor hash. Two calls made in-session (both the recommended option): **referrers are stored host-only** — full URLs would carry query strings into the store against the privacy stance, and hosts are what the dashboard aggregates — and the **salt derives from `SESSION_SECRET`** (`HMAC(secret, 'mooring-visitor:' + UTC date)`, the literal prefix domain-separating it from cookie signing) so self-host stays one-secret and there is nothing to rotate or store. Bots drop at ingest via `isbot` (new dependency; learned in testing: it flags a bare `Mozilla/5.0` as headless, so test fixtures need full UA token chains). App-host traffic records under the new `METRICS_APP_DID` var (the authority DID); tenant traffic under the tenant. The query layer (`lib/server/metrics/query.ts`) is **sampling-aware from day one** — every count is `sum(_sample_interval)`, and a test literally greps the SQL to fail any bare `count()` that sneaks in; DIDs are shape-validated before inlining into SQL. `/admin/analytics` (nav link added) renders tiles, a 7-bar views-by-day chart (peak labeled, zero days as rule-colored stubs), and top pages / referrers / countries with `Intl.DisplayNames` country names — plus the locked "30 days · paid tier" chip, the retroactivity promise, and the three-month horizon stated honestly. Distinct states for unconfigured (names the missing var/secret; says recording still works), store-didn't-answer, and empty. Self-host seam: no `METRICS` binding → ingest no-ops, the route 404s. 36 new tests (260 in `web`); verified in a browser through a temporary preview route (all four states, both schemes, desktop + 375px, no console errors), deleted before commit. **Nothing is live yet**: deploy steps and live smoke-test are item 2 above. +- 2026-08-27 — **Site metrics built end to end** (PD-11's build session; PR #45, **merged and deployed 2026-08-27**, Worker version `5102460b`). ADR 0016 (Proposed) fixes the specifics: dataset `mooring_site_views` bound as `METRICS`, one data point per rendered HTML page view (GET · 200 · `text/html`) written from `hooks.server.ts` under `waitUntil` — the one place every tenant host has already resolved to a DID — with `index1` = site DID and blobs path / referrer host / country / daily visitor hash. Two calls made in-session (both the recommended option): **referrers are stored host-only** — full URLs would carry query strings into the store against the privacy stance, and hosts are what the dashboard aggregates — and the **salt derives from `SESSION_SECRET`** (`HMAC(secret, 'mooring-visitor:' + UTC date)`, the literal prefix domain-separating it from cookie signing) so self-host stays one-secret and there is nothing to rotate or store. Bots drop at ingest via `isbot` (new dependency; learned in testing: it flags a bare `Mozilla/5.0` as headless, so test fixtures need full UA token chains). App-host traffic records under the new `METRICS_APP_DID` var (the authority DID); tenant traffic under the tenant. The query layer (`lib/server/metrics/query.ts`) is **sampling-aware from day one** — every count is `sum(_sample_interval)`, and a test literally greps the SQL to fail any bare `count()` that sneaks in; DIDs are shape-validated before inlining into SQL. `/admin/analytics` (nav link added) renders tiles, a 7-bar views-by-day chart (peak labeled, zero days as rule-colored stubs), and top pages / referrers / countries with `Intl.DisplayNames` country names — plus the locked "30 days · paid tier" chip, the retroactivity promise, and the three-month horizon stated honestly. Distinct states for unconfigured (names the missing var/secret; says recording still works), store-didn't-answer, and empty. Self-host seam: no `METRICS` binding → ingest no-ops, the route 404s. 36 new tests (260 in `web`); verified in a browser through a temporary preview route (all four states, both schemes, desktop + 375px, no console errors), deleted before commit. **Found on the first live read** (dashboard showed "the metrics store didn't answer"; `wrangler tail` named it): `query.ts` invoked its injected fetch as `api.fetch(...)`, a method call that hands the config object to `fetch` as `this` — **workerd rejects a foreign receiver (Illegal invocation) where Node's fetch is lenient**, so 260 green tests never saw it. PR #46 (open) makes the one call site `api.fetch.call(globalThis, …)` and pins the receiver in a test (261 in `web`). Ingest was never affected — `writeDataPoint` is a true method call — so views recorded since the #45 deploy are all retroactively readable once #46 ships. Live smoke-test = item 2 above. - 2026-08-27 — **A section that stops short says so, and says where the rest is** (PR #43, **open**). Posts and writing rendered a fixed head of their collections and simply stopped: a reader with 342 posts saw five, with nothing on the page saying so and nowhere to go for the rest, while the career log rendered every record — three inconsistent volume behaviours on one page. Jacob's calls, all three the recommended option: **a stamped cue that links out** rather than an in-place disclosure or an archive route (quote hydration deliberately runs only over the *shown* posts — each quote costs a DID-document resolution plus a post read — so revealing forty more would either pay for forty more of those or render the hidden ones bare); **the career log stays whole** (a résumé's point is showing the log, its lines are compact, and a truncated work history reads as concealment rather than curation — so `positions`/`education` are untouched, and the existing `SKILL_CAP` disclosure stands); and **the default post count moves 5 → 8**, which an honest cue makes affordable. A trimmed list now closes with one line in the theme's system voice — mono, accent, above a dotted rule that replaces the list's own last border: `8 of 342 · the rest on Bluesky ↗`. **The count turned out to be free**: both adapters already page the entire collection (`listAllRecords`, 10 × 100) before slicing, so they now return it — `fetchBlueskyPosts` → `{ posts, total }`, `fetchDocuments` → `{ documents, total, publication? }` (replies count towards neither). **The line only renders when there is somewhere to send the reader** — a count with no destination is a tease, not an affordance. Posts always have one, the owner's Bluesky profile by DID, matching the two links `SiteLayout` already builds. Writing has one when every listed document belongs to a single publication that publishes an address; documents spread across publications, or `pub.leaflet.*` legacy records which name none, get no line, and the label is the publication's name falling back to its host. Resolving that publication costs a read, so it is only attempted once a listing is known to be short, the two things wanting publication records now share one read instead of two, and a failed read costs the cue rather than the listing. No lexicon change, no record migration, no new route. 7 new tests (224 in `web`); verified in a browser through a temporary preview route covering posts-trimmed, posts-whole, writing-with-a-named-publication and writing-falling-back-to-its-host in both schemes, deleted before commit — accent-on-paper measured 6.58:1 light / 5.46:1 dark, 36px tall, no horizontal overflow at 375px. Checked against live PDS data: `jzweifel.dev` has five non-reply posts, so it is trimmed by neither the old limit nor the new one and renders unchanged. **One gap:** no account in hand exercises the cue against live data — it is covered by the tests and the dev preview, not by a deployed site; look the first time a heavier poster's site is rendered.