diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1483c7..37adaa6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,23 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm run check + screenshots: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + with: + version: 10.32.1 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm screenshot + - uses: actions/upload-artifact@v7 + with: + name: emdash-openanalytics-screenshots + path: docs/screenshots/*.png + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 34370a2..4d2ef35 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ dist/ coverage/ +.astro/ +demo/emdash-env.d.ts .turbo/ .DS_Store *.tgz diff --git a/README.md b/README.md index 03155c6..b97eabc 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Native EmDash CMS integration for OpenAnalytics, by Black Swamp AI. Native connection validation, public-site tracker installation, and an analytics overview inside EmDash. The admin page uses EmDash Block Kit controls, metric -cards, notices, and a timeseries chart. Private credentials stay on the server. +cards, notices, a timeseries chart, Top Pages, and Traffic Sources. Private +credentials stay on the server. ## Installation @@ -103,8 +104,19 @@ numbers are easier to interpret. Missing freshness is shown as unavailable. Authentication failures, missing analytics scope, suspended service, rate limits, and unavailable upstream service have safe messages and validation/retry controls. -Opening the page or changing its range makes two server-side reads: overview and -timeseries. Revalidation first reads site metadata and refreshes the installation +Top Pages shows the first ten pages ranked by views, with views and visitors. +Traffic Sources shows the first ten attribution groups ranked by views, with +views and visitors. Campaign source, medium, and campaign values appear when +recorded; rows with the same referrer can represent different campaigns. +Untagged rows with an empty referrer are shown as Direct / internal. Tagged +rows keep their recorded UTM source. +These are per-row visitor counts and should not be summed into the overview. +Empty reports have their own messages. A failed report leaves other successful +sections visible, and each section reports its own data caveats. + +Opening the page or changing its range makes four server-side reads: overview, +timeseries, pages, and sources. All reuse one requested interval and timezone. +Revalidation first reads site metadata and refreshes the installation snapshot. There is no polling, automatic retry, or shared analytics cache. The private admin route requires `plugins:manage` and EmDash's CSRF protection. @@ -152,14 +164,17 @@ manual tracker URL overrides. ## Current limitations -- The overview is intentionally small: no top pages, sources, sessions, funnels, - revenue, visitor profiles, editor analytics, or realtime polling. +- The summary is intentionally small: no pagination, custom filters, geography, + devices, sessions browser, individual visitors, custom-event reports, funnels, + revenue, web vitals, editor analytics, or realtime polling. OAuth and account + or site creation are also outside this plugin's current scope. - Revalidate after tracker rotation or a collector URL change. Saved installation metadata has no automatic expiry; private-key revocation is detected on validation. - Static pages receive the snapshot available when they are rendered. Rebuild those pages after changing the connection or tracking switch. - The tracker independently fetches OpenAnalytics's browser configuration. A - suspended site may validate successfully; ingestion follows OpenAnalytics policy. + suspended site may validate successfully and still have its tracker installed; + collection and analytics access follow OpenAnalytics's suspension policy. - Duplicate protection covers EmDash fragments in a placement. Remove any tracker tag already installed manually in the theme. @@ -173,6 +188,11 @@ pnpm check `check` runs typechecking, linting, formatting checks, tests, build, and package verification. CI runs the same checks. No npm publication is performed. +Run `pnpm exec playwright install chromium`, then `pnpm screenshot` to generate +three screenshots from a local EmDash admin instance using synthetic analytics. +See the [demo instructions](demo/README.md) for the authentication, fixture, +artifact safety checks, and screenshot locations. CI also runs this workflow. + Source boundaries are the native plugin entry, settings/configuration, the server-side OpenAnalytics client, saved connection state, tracker fragments, and native admin page. diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..e2a5ef8 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,9 @@ +# EmDash OpenAnalytics screenshot demo + +Run `pnpm screenshot` from the repository root. It builds `dist/`, starts a local OpenAnalytics fixture and an actual EmDash Astro admin using the packaged plugin descriptor, authenticates through EmDash's development-only bypass, configures the fixture connection through the protected settings API, validates it, and captures three screenshots under `docs/screenshots/`. + +The fixture and EmDash app bind to `127.0.0.1` on reserved ephemeral ports. Their readiness routes require a per-run random token, and EmDash also refuses to start if its configured port is occupied. The fixture accepts only its synthetic `oa_sk_demo_fixture_only_…` key. The EmDash demo database lives in a newly created temporary directory and is removed on exit. Captures are staged in another temporary directory; before they are copied into the repository, the harness scans rendered DOM text, page HTML, text/JSON browser responses, process output, and PNG bytes for private-key-shaped values and the fixture key. This environment has no OCR tool, so the screenshot check supplements the scan of the exact DOM used to take each image. + +The fixture keeps totals, comparison values, report rows, and chart shape fixed. It echoes the selected request range and timezone in response metadata and verifies overview, timeseries, pages, and sources all receive one matching interval. Request bounds and connection-validation time follow the current system clock; no production code is patched to freeze time. The chart generates points within the selected window. This gives stable content and layout while preserving the real admin range behavior. + +EmDash's `/_emdash/api/setup/dev-bypass` is used only on the loopback-bound Astro development server. EmDash guards this route behind `import.meta.env.DEV`. No dev bypass is exposed by the screenshot harness on a non-loopback address. diff --git a/demo/astro.config.mjs b/demo/astro.config.mjs new file mode 100644 index 0000000..b2488cd --- /dev/null +++ b/demo/astro.config.mjs @@ -0,0 +1,34 @@ +import { fileURLToPath } from "node:url"; + +import node from "@astrojs/node"; +import react from "@astrojs/react"; +import { defineConfig } from "astro/config"; +import emdash from "emdash/astro"; +import { sqlite } from "emdash/db"; + +import { openAnalytics } from "../dist/index.mjs"; + +const descriptor = openAnalytics(); + +export default defineConfig({ + output: "server", + adapter: node({ mode: "standalone" }), + devToolbar: { enabled: false }, + server: { + host: "127.0.0.1", + port: Number(process.env.EMDASH_DEMO_PORT ?? 4390), + strictPort: true, + }, + integrations: [ + react(), + emdash({ + database: sqlite({ url: process.env.EMDASH_DEMO_DB_URL ?? "file:./data.db" }), + plugins: [ + { + ...descriptor, + entrypoint: fileURLToPath(new URL("../dist/index.mjs", import.meta.url)), + }, + ], + }), + ], +}); diff --git a/demo/src/pages/demo-health.ts b/demo/src/pages/demo-health.ts new file mode 100644 index 0000000..78c7d9f --- /dev/null +++ b/demo/src/pages/demo-health.ts @@ -0,0 +1,12 @@ +import type { APIRoute } from "astro"; + +export const prerender = false; + +export const GET: APIRoute = ({ request }) => { + const expected = process.env.EMDASH_DEMO_RUN_ID; + const supplied = new URL(request.url).searchParams.get("run"); + if (!expected || supplied !== expected) return new Response("Not found", { status: 404 }); + return new Response(JSON.stringify({ run: expected }), { + headers: { "Content-Type": "application/json" }, + }); +}; diff --git a/demo/tsconfig.json b/demo/tsconfig.json new file mode 100644 index 0000000..a3f6981 --- /dev/null +++ b/demo/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "astro/tsconfigs/strict" +} diff --git a/docs/implementation-footprint.md b/docs/implementation-footprint.md index 923ced1..eac3446 100644 --- a/docs/implementation-footprint.md +++ b/docs/implementation-footprint.md @@ -7,11 +7,12 @@ counts include `src/**/*.ts` for EmDash and `nodes/**/*.ts` plus fixtures/helpers. Lockfiles, generated code, docs, CI, and package tooling are excluded. These are size comparisons, not runtime performance measurements. -| Implementation | Production LOC | Test LOC | Production modules | OpenAnalytics HTTP endpoints | -| ----------------------------- | -------------: | -------: | -----------------: | ---------------------------: | -| EmDash scaffold, PR #1 | 495 | 721 | 9 | 1 | -| EmDash native overview, PR #2 | 1458 | 1923 | 10 | 3 | -| n8n OpenAnalytics 0.1.1 | 607 | 613 | 5 | 11 | +| Implementation | Production LOC | Test LOC | Production modules | OpenAnalytics HTTP endpoints | +| ------------------------------- | -------------: | -------: | -----------------: | ---------------------------: | +| EmDash scaffold, PR #1 | 495 | 721 | 9 | 1 | +| EmDash native overview, PR #2 | 1458 | 1923 | 10 | 3 | +| EmDash pages and sources, PR #3 | 1770 | 2423 | 14 | 5 | +| n8n OpenAnalytics 0.1.1 | 607 | 613 | 5 | 11 | PR #2 adds **963 production lines** and **1202 test lines** over the scaffold. The scaffold tree at `a9e69a2` is identical to the original @@ -19,6 +20,12 @@ over the scaffold. The scaffold tree at `a9e69a2` is identical to the original `8caaa20c6385f6fba1fcf2e8c2dbec0a1ac5efb1` from the local `@blackswampai/n8n-nodes-openanalytics` checkout. +PR #3 adds **312 production lines** and **500 test lines** over merged PR #2 +(`2865caa`). It supports site metadata, overview, timeseries, pages, and sources. +The admin coordinator delegates range handling, connection blocks, overview/chart +rendering, and report tables to four small modules. Successful sections remain +visible when another read fails. + The EmDash plugin now owns a native admin page, public tracker insertion, credential-bound installation snapshots, secure read transport, response validation/projection, and useful connection/error/freshness states. The n8n @@ -28,13 +35,15 @@ The size difference reflects those different responsibilities. EmDash production modules remain narrowly scoped: plugin/descriptor, connection state, configuration/settings, OpenAnalytics transport/errors/types, tracker -fragment, and one admin page. PR #2 adds no production dependencies, custom +fragment, and native admin modules. PR #3 adds no production dependencies, custom browser bundle, chart library, application framework, polling, or shared cache. `@emdash-cms/blocks@1.0.1` is a development dependency for type-only authoring and upstream response validation in tests; EmDash provides its renderer at runtime. -Analytics use exactly two HTTP reads per requested overview: overview (including -its server-provided preceding-period totals) and timeseries. Revalidation adds +Analytics use exactly four HTTP reads per interaction: overview (including +its server-provided preceding-period totals), timeseries, pages, and sources. +All reuse one requested interval and timezone. Pages and sources each request +the first ten rows ranked by views. Revalidation adds one site read. Public page rendering adds no read-key requests. A transient revalidation failure returns its error and preserves a matching tracker snapshot without starting additional analytics requests. @@ -43,3 +52,27 @@ To reproduce the line counts, enumerate the directories above, include only `.ts` files, and sum their physical lines. For baseline comparison, read those same files from `git show a9e69a2:`; for n8n, read files at the pinned commit. Apply the same formatting/measurement convention in future PRs. + +| Implementation | Runtime dependencies | Development dependencies | Host peer | +| ----------------------- | -------------------: | -----------------------: | -------------- | +| EmDash PR #2 | 0 | 8 | `emdash` | +| EmDash PR #3 | 0 | 14 | `emdash` | +| n8n OpenAnalytics 0.1.1 | 0 | 7 | `n8n-workflow` | + +PR #3 adds only development tooling: Astro, its Node and React adapters and React peers +for EmDash's host admin, and Playwright. The plugin still uses native Block Kit +and has no browser entrypoint. The React dependencies serve EmDash itself in +the demo; they add no React application to the plugin. The demo runs an actual +EmDash instance with an isolated temporary SQLite database and a loopback +OpenAnalytics HTTP fixture. Screenshots come from its authenticated admin page +and are checked for credential exposure before being retained. Demo code and +screenshots are excluded from npm. + +Package sizes are measured with `npm pack --ignore-scripts --dry-run --json` +after building; packed size is the compressed tarball, not dependency install +size. EmDash PR #3 packs to **about 23.7 kB** (decimal), compared with +**19.3 kB** for PR #2. The existing n8n checkout at the pinned commit packs to +**8,919 bytes**. +Its package includes compiled workflow operations, source maps, and icons; the +EmDash package includes the compiled plugin, declarations, README, and two +contract/footprint documents. Package size reflects those different contents. diff --git a/docs/screenshots/openanalytics-narrow.png b/docs/screenshots/openanalytics-narrow.png new file mode 100644 index 0000000..77fcee4 Binary files /dev/null and b/docs/screenshots/openanalytics-narrow.png differ diff --git a/docs/screenshots/openanalytics-overview.png b/docs/screenshots/openanalytics-overview.png new file mode 100644 index 0000000..190d250 Binary files /dev/null and b/docs/screenshots/openanalytics-overview.png differ diff --git a/docs/screenshots/openanalytics-reports.png b/docs/screenshots/openanalytics-reports.png new file mode 100644 index 0000000..5adb040 Binary files /dev/null and b/docs/screenshots/openanalytics-reports.png differ diff --git a/docs/upstream-contracts.md b/docs/upstream-contracts.md index ad7bafd..a99e2fd 100644 --- a/docs/upstream-contracts.md +++ b/docs/upstream-contracts.md @@ -51,9 +51,10 @@ The published `@emdash-cms/blocks@1.0.1` types define headers, fields, actions, selects/buttons, stats, banners, context, and a native timeseries chart with `config.chart_type: "timeseries"` and `[timestamp_ms, value]` points. The host owns chart rendering, typography, spacing, navigation, and initial loading. -Forms, tables (including badge cells), and tabs are available but unnecessary -for this page. There is no standalone status badge or plugin-owned loading -block; banners/fields represent connection state. +Forms, tables (including badge cells), and tabs are available; this page now +uses native tables for Top Pages and Traffic Sources. There is no standalone +status badge or plugin-owned loading block; banners/fields represent connection +state. Only Block Kit **types** are imported in production; the blocks package is a development dependency and its React/chart renderer is not bundled. Tests use @@ -145,12 +146,83 @@ and [read-key route implementation](https://github.com/OpenLabs-so/openanalytics can return HTTP 400. The CMS guide recommends reads on actual admin use and warns against sharing a -read-key response cache across administrators. Each overview interaction makes -only two analytics reads; validation explicitly adds one site read. No polling, -automatic retries, or extra read endpoints are introduced. +read-key response cache across administrators. Each analytics page interaction +reads overview, timeseries, pages, and sources; validation adds one site read. +No polling or automatic retries are introduced. The native timeseries chart has no timezone formatting option. OpenAnalytics aligns the returned buckets to the configured timezone, and the page formats range/freshness text in that timezone, but native chart tick/tooltips use the administrator's browser timezone. The UI discloses this and uses a neutral axis label; timestamps are never shifted to fake timezone formatting. + +## Top pages and traffic sources + +Verified again against OpenAnalytics `main` at +[`f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9`](https://github.com/OpenLabs-so/openanalytics/tree/f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9) +on 2026-09-29; `git ls-remote origin refs/heads/main` matched this SHA. + +The plugin uses these private read-key operations: + +```http +GET /v1/read/analytics/pages?from=...&to=...&timezone=...&limit=10&sort=views +GET /v1/read/analytics/sources?from=...&to=...&timezone=...&limit=10 +``` + +Both require `from`, `to`, and `timezone`. The instants are ISO-8601 UTC and +form a half-open interval: `from` is included and `to` is excluded. `timezone` +is an IANA timezone used to interpret calendar boundaries. `limit` is optional +and accepts integers 1 through 500 (default 100); this plugin requests 10 rows. +Pages also accepts `sort` (`views`, `entrances`, or `exits`, default `views`) +and both endpoints accept optional session-scoped `filters`. The site selector +header is not sent with a private site-bound key; it is for OAuth credentials. + +Pages returns `{ meta, items }`. A row requires `page_path`, `views`, +`visitors`, `entrances`, `exits`, `bounces`, and `bounce_rate`. Counts are +nonnegative integers. Session measures may be null: null means the metric was +not measured, the session-decoration read did not cover that path, or the row +is imported; zero means the server measured zero. `entrances` counts sessions +that began on a path, `exits` sessions ending there, and `bounces` unengaged +sessions counted on their entry path. `bounce_rate` is `bounces / entrances` +and is null when no denominator was measured. Pages are ranked and cut by the +server according to `sort`; re-sorting a views-limited response in the client +would misrepresent the top-N result. + +Sources returns `{ meta, items }`. A row requires the string tuple +`referrer_domain`, `utm_source`, `utm_medium`, and `utm_campaign`, plus +nonnegative integer `views` and `visitors`. The canonical external referrer is +a lowercase host without `www`, port, or path. Empty `referrer_domain` means +Direct and also includes internal navigation; historical stored spellings were +not rewritten. UTM fields may be empty. Rows represent attribution tuples, not +sessions, and the report is ranked/cut by views. + +Both responses require metadata describing requested and effective ranges, +timezone, resolution, data sources, accuracy, freshness, comparison range, +truncation, cache state, and partial state. For these reports +`comparison_range` is null. Freshness state distinguishes `no_data`, `ok`, +`stale`, and `degraded`. Reports can merge live events with imported provider +data; metadata identifies sources and whether results are exact, +provider-defined, or estimated. A legitimate empty result is an empty `items` +array, not an error. + +The documented HTTP responses are 400 for invalid/unservable ranges, 401 for +missing/invalid authentication, 403 for missing analytics scope or suspended +site, 404 for unknown site, 429 for rate limiting, and 503 when analytics +storage is unavailable. Error envelopes carry stable codes, including +`FORBIDDEN`, `SITE_SUSPENDED`, `RANGE_TOO_LARGE`, and +`SERVICE_UNAVAILABLE`; clients should not display response messages as trusted +content. A suspended site closes analytics reads with `SITE_SUSPENDED`; +tracker installation state is a separate concern. Hosted deployments may +have a limited billing-grace period for ingest, governed by the collector's +admission policy. + +Primary source locations in that pinned revision: + +- `packages/contracts/openapi/openapi.yaml:1529-1594` — paths, auth and HTTP responses. +- `packages/contracts/openapi/openapi.yaml:8468-8505` — analytics metadata and freshness. +- `packages/contracts/openapi/openapi.yaml:8777-8889` — pages and sources row schemas. +- `packages/contracts/openapi/openapi.yaml:12030-12079,12152-12161,12218-12237` — site selector, range/timezone, limit and pages sort. +- `apps/api/src/http/read-key.ts:896-920` — private-key pages read and session decoration. +- `apps/api/src/analytics/service.ts:1036-1072,1168-1180` — pages session decoration/ranking and sources view ranking. +- `apps/api/src/http/middleware.ts:183-197` and `apps/api/src/http/read-key.ts:600-608` — suspended-site analytics gate. +- `apps/tracker/src/core.ts:81-90` and `apps/collector/src/ingest-config-store.ts:215-270` — tracker stand-down and collector admission during suspension. diff --git a/package.json b/package.json index 35c0a10..4fc37b3 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ }, "files": [ "dist", - "docs" + "docs/implementation-footprint.md", + "docs/upstream-contracts.md" ], "type": "module", "main": "./dist/index.mjs", @@ -34,6 +35,8 @@ "scripts": { "build": "tsdown src/index.ts --format esm --dts --clean", "dev": "tsdown src/index.ts --format esm --dts --watch", + "demo": "npm run screenshot", + "screenshot": "npm run build && node scripts/screenshot-demo.mjs", "typecheck": "tsc --noEmit", "test": "vitest run", "lint": "oxlint --deny-warnings", @@ -44,11 +47,17 @@ "prepublishOnly": "npm run check" }, "devDependencies": { + "@astrojs/node": "11.1.6", + "@astrojs/react": "7.0.0", "@emdash-cms/blocks": "1.0.1", "@types/node": "24.10.1", + "astro": "7.3.5", "emdash": "1.0.1", "oxfmt": "0.59.0", "oxlint": "1.74.0", + "playwright": "1.61.1", + "react": "19.3.0", + "react-dom": "19.3.0", "tsdown": "0.20.3", "typescript": "5.9.3", "vitest": "4.0.15" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b6fef2..21f633a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,21 @@ importers: .: devDependencies: + '@astrojs/node': + specifier: 11.1.6 + version: 11.1.6(astro@7.3.5(@types/node@24.10.1)) + '@astrojs/react': + specifier: 7.0.0 + version: 7.0.0(@types/node@24.10.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@emdash-cms/blocks': specifier: 1.0.1 version: 1.0.1(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) '@types/node': specifier: 24.10.1 version: 24.10.1 + astro: + specifier: 7.3.5 + version: 7.3.5(@types/node@24.10.1) emdash: specifier: 1.0.1 version: 1.0.1(@astrojs/react@7.0.0(@types/node@24.10.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@24.10.1))(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3) @@ -23,6 +32,15 @@ importers: oxlint: specifier: 1.74.0 version: 1.74.0 + playwright: + specifier: 1.61.1 + version: 1.61.1 + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) tsdown: specifier: 0.20.3 version: 0.20.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(typescript@5.9.3) @@ -112,6 +130,11 @@ packages: '@astrojs/markdown-satteri@0.4.2': resolution: {integrity: sha512-g4QehXnB/MzxQWnSf17NciFInMqEJy9948p8cc7zGg/r7995m0Ln5PMr3Xlnj24iey1LchaoZ3SzTOa3D4P/3w==} + '@astrojs/node@11.1.6': + resolution: {integrity: sha512-jNZ10/vAT+HuXkX0E/tITsIYCSn3BmAvsooVAN1OjagG+FEzelpUWToy1bA3x6Irmu0VaknWRfReY+1twd35sQ==} + peerDependencies: + astro: ^7.2.1 + '@astrojs/prism@4.0.2': resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} engines: {node: '>=22.12.0'} @@ -2614,6 +2637,11 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3160,6 +3188,16 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -3393,6 +3431,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + server-destroy@1.0.1: + resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -3987,6 +4028,15 @@ snapshots: github-slugger: 2.0.0 satteri: 0.10.5 + '@astrojs/node@11.1.6(astro@7.3.5(@types/node@24.10.1))': + dependencies: + '@astrojs/internal-helpers': 0.11.0 + astro: 7.3.5(@types/node@24.10.1) + send: 1.2.1 + server-destroy: 1.0.1 + transitivePeerDependencies: + - supports-color + '@astrojs/prism@4.0.2': dependencies: prismjs: 1.30.0 @@ -6351,6 +6401,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -6861,6 +6914,14 @@ snapshots: pkce-challenge@5.0.1: {} + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.28: dependencies: nanoid: 3.3.19 @@ -7226,6 +7287,8 @@ snapshots: transitivePeerDependencies: - supports-color + server-destroy@1.0.1: {} + setprototypeof@1.2.0: {} sharp@0.35.5(@types/node@24.10.1): diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 4368e4c..48f7b11 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; const root = resolve(import.meta.dirname, ".."); const pkg = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")); @@ -33,4 +36,23 @@ for (const file of await readdir(resolve(root, "dist"))) { const contents = await readFile(resolve(root, "dist", file), "utf8"); assert(!/oa_sk_[A-Za-z0-9_-]{8,}/.test(contents), `Private credential in ${file}`); } -console.log("Package exports, native metadata, and artifact credential scan passed."); +const cache = mkdtempSync(join(tmpdir(), "openanalytics-package-check-")); +let packed; +try { + [packed] = JSON.parse( + execFileSync("npm", ["pack", "--ignore-scripts", "--dry-run", "--json", "--cache", cache], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }), + ); +} finally { + rmSync(cache, { recursive: true, force: true }); +} +for (const { path } of packed.files) { + assert(!/^(demo|scripts|tests)\//.test(path), `Development fixture packed: ${path}`); + assert(!path.startsWith("docs/screenshots/"), `Screenshot packed: ${path}`); + const contents = await readFile(resolve(root, path), "utf8"); + assert(!/oa_sk_[A-Za-z0-9_-]{8,}/.test(contents), `Private credential packed: ${path}`); +} +console.log("Package exports, native metadata, packed files, and artifact credential scan passed."); diff --git a/scripts/demo-openanalytics.mjs b/scripts/demo-openanalytics.mjs new file mode 100644 index 0000000..384f745 --- /dev/null +++ b/scripts/demo-openanalytics.mjs @@ -0,0 +1,208 @@ +import { createServer } from "node:http"; + +const PORT = Number(process.env.OA_FIXTURE_PORT ?? 4389); +const RUN_ID = process.env.OA_FIXTURE_RUN_ID ?? "demo-only"; +const SYNTHETIC_KEY = "oa_sk_demo_fixture_only_00000000000000000000000000000000"; +const STABLE_NOW = "2026-09-29T15:45:00.000Z"; +const SERIES_VALUES = [58, 72, 64, 91, 106, 88, 112, 94, 126, 119, 101, 138, 147, 168]; +const observedReads = []; + +function send(res, status, body) { + res.writeHead(status, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(body)); +} + +function requestInfo(req, report = false) { + const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`); + const from = url.searchParams.get("from") ?? "2026-08-30T16:00:00.000Z"; + const to = url.searchParams.get("to") ?? "2026-09-29T16:00:00.000Z"; + const span = Date.parse(to) - Date.parse(from); + const prior = { from: new Date(Date.parse(from) - span).toISOString(), to: from }; + const timezone = url.searchParams.get("timezone") ?? "America/New_York"; + const isComparison = !report && url.searchParams.get("compare") === "true"; + return { + from, + to, + limit: Number(url.searchParams.get("limit") ?? 10), + meta: { + requested_range: { from, to }, + effective_range: { from, to }, + timezone, + resolution: url.searchParams.get("resolution") ?? (report ? "day" : "hour"), + data_sources: ["live"], + accuracy: "exact", + freshness: { state: "ok", watermark: STABLE_NOW, as_of: STABLE_NOW }, + comparison_range: isComparison ? prior : null, + truncated: false, + cached: false, + partial: false, + }, + }; +} + +const server = createServer((req, res) => { + if (new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`).pathname === "/__health") { + const supplied = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`).searchParams.get("run"); + return supplied === RUN_ID + ? send(res, 200, { ok: true, run: RUN_ID }) + : send(res, 404, { error: { code: "NOT_FOUND" } }); + } + if (req.url === "/__requests") return send(res, 200, observedReads); + if (req.headers.authorization !== `Bearer ${SYNTHETIC_KEY}`) + return send(res, 401, { error: { code: "UNAUTHORIZED", message: "fixture auth required" } }); + if (req.method !== "GET") return send(res, 405, { error: { code: "METHOD_NOT_ALLOWED" } }); + const path = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`).pathname; + if (path.startsWith("/v1/read/analytics/")) { + const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`); + observedReads.push({ + path, + from: url.searchParams.get("from"), + to: url.searchParams.get("to"), + timezone: url.searchParams.get("timezone"), + limit: url.searchParams.get("limit"), + }); + } + if (path === "/v1/read/site") { + return send(res, 200, { + site_id: "site_demo_openanalytics", + slug: "emdash-demo", + name: "EmDash Demo", + status: "active", + install: { + tracking_key: "oa_pk_demo_tracking_00000000000000000000000000000000", + script_url: `http://127.0.0.1:${PORT}/tracker.js`, + collector_url: `http://127.0.0.1:${PORT}/v1/collect`, + }, + }); + } + if (path === "/v1/read/analytics/overview") { + const request = requestInfo(req); + return send(res, 200, { + totals: { visitors: 1284, pageviews: 2911, events: 4201, billable_events: 4201 }, + comparison: request.meta.comparison_range + ? { totals: { visitors: 1102, pageviews: 2458, events: 3660, billable_events: 3660 } } + : null, + meta: request.meta, + }); + } + if (path === "/v1/read/analytics/timeseries") { + const request = requestInfo(req); + const from = Date.parse(request.from); + const span = Date.parse(request.to) - from; + const series = SERIES_VALUES.map((visitors, i) => { + const bucket = new Date(from + span * ((i + 0.5) / SERIES_VALUES.length)); + return { + bucket: bucket.toISOString(), + visitors, + pageviews: visitors * 2 + (i % 3) * 5, + events: visitors * 3 + (i % 4) * 7, + }; + }); + return send(res, 200, { series, meta: request.meta, comparison: null }); + } + if (path === "/v1/read/analytics/pages") { + const request = requestInfo(req, true); + return send(res, 200, { + items: [ + { + page_path: "/", + views: 1482, + visitors: 904, + entrances: 514, + exits: 302, + bounces: 201, + bounce_rate: 0.392, + }, + { + page_path: "/blog/openanalytics", + views: 712, + visitors: 518, + entrances: 210, + exits: 177, + bounces: 86, + bounce_rate: 0.41, + }, + { + page_path: "/workflows/content-operations", + views: 431, + visitors: 302, + entrances: 108, + exits: 95, + bounces: 39, + bounce_rate: 0.36, + }, + { + page_path: "/contact", + views: 219, + visitors: 184, + entrances: 97, + exits: 81, + bounces: 44, + bounce_rate: 0.45, + }, + ].slice(0, request.limit), + meta: request.meta, + }); + } + if (path === "/v1/read/analytics/sources") { + const request = requestInfo(req, true); + return send(res, 200, { + items: [ + { + referrer_domain: "", + utm_source: "newsletter", + utm_medium: "email", + utm_campaign: "autumn_launch", + views: 511, + visitors: 482, + }, + { + referrer_domain: "", + utm_source: "", + utm_medium: "", + utm_campaign: "", + views: 393, + visitors: 361, + }, + { + referrer_domain: "google.com", + utm_source: "", + utm_medium: "", + utm_campaign: "", + views: 327, + visitors: 299, + }, + { + referrer_domain: "reddit.com", + utm_source: "", + utm_medium: "", + utm_campaign: "", + views: 205, + visitors: 177, + }, + { + referrer_domain: "linkedin.com", + utm_source: "", + utm_medium: "social", + utm_campaign: "openanalytics_guide", + views: 144, + visitors: 126, + }, + { + referrer_domain: "github.com", + utm_source: "", + utm_medium: "", + utm_campaign: "", + views: 92, + visitors: 84, + }, + ].slice(0, request.limit), + meta: request.meta, + }); + } + return send(res, 404, { error: { code: "NOT_FOUND" } }); +}); + +server.listen(PORT, "127.0.0.1", () => console.log(`[oa-fixture] listening on 127.0.0.1:${PORT}`)); +for (const signal of ["SIGINT", "SIGTERM"]) + process.on(signal, () => server.close(() => process.exit(0))); diff --git a/scripts/screenshot-demo.mjs b/scripts/screenshot-demo.mjs new file mode 100644 index 0000000..757b2d4 --- /dev/null +++ b/scripts/screenshot-demo.mjs @@ -0,0 +1,369 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; + +import { chromium } from "playwright"; + +const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); +const SCREENSHOTS = join(ROOT, "docs/screenshots"); +const usedPorts = new Set(); +async function availablePort() { + while (true) { + const port = await new Promise((resolvePort, rejectPort) => { + const socket = createServer(); + socket.once("error", rejectPort); + socket.listen(0, "127.0.0.1", () => { + const address = socket.address(); + if (!address || typeof address === "string") + return rejectPort(new Error("Could not reserve a port")); + socket.close((error) => (error ? rejectPort(error) : resolvePort(address.port))); + }); + }); + if (usedPorts.has(port)) continue; + usedPorts.add(port); + return port; + } +} +const OA_PORT = await availablePort(); +const PORT = await availablePort(); +const runId = randomUUID(); +const BASE = `http://127.0.0.1:${PORT}`; +const FIXTURE_BASE = `http://127.0.0.1:${OA_PORT}`; +const SYNTHETIC_KEY = "oa_sk_demo_fixture_only_00000000000000000000000000000000"; +const SECRET_PATTERN = /oa_sk_[a-zA-Z0-9_-]{12,}/g; +const staging = await mkdtemp(join(tmpdir(), "openanalytics-ui-shot-")); +const childLogs = []; +const responseText = []; +const children = []; +let browser; + +function start(command, args, label, env = {}) { + const child = spawn(command, args, { + cwd: label === "emdash" ? join(ROOT, "demo") : ROOT, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + children.push(child); + for (const stream of [child.stdout, child.stderr]) { + let pending = ""; + stream.setEncoding("utf8"); + stream.on("data", (chunk) => { + pending += chunk; + const lines = pending.split("\n"); + pending = lines.pop() ?? ""; + for (const line of lines) childLogs.push(`[${label}] ${line}`); + }); + } + return child; +} + +async function waitFor(url, child, label, matchRunId = false) { + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) + throw new Error( + `${label} exited (${child.exitCode}) before becoming ready.\n${childLogs.slice(-30).join("\n")}`, + ); + let response; + try { + response = await fetch(url, { signal: AbortSignal.timeout(1500) }); + } catch { + // The process has not bound its loopback port yet. + await delay(250); + continue; + } + if (!response.ok) { + if (matchRunId && response.status === 404) + throw new Error(`${label} readiness route was not found at ${url}.`); + await delay(250); + continue; + } + if (matchRunId) { + const body = await response.json(); + if (body.run !== runId) throw new Error(`${label} readiness token did not match this run.`); + } + if (child.exitCode !== null) throw new Error(`${label} process exited after its health check.`); + return; + } + throw new Error(`${label} did not become ready at ${url}.\n${childLogs.slice(-30).join("\n")}`); +} + +async function requireOk(response, label) { + const body = await response.text(); + responseText.push(`${label}: ${body}`); + if (!response.ok()) throw new Error(`${label} failed (${response.status()})`); + try { + return JSON.parse(body); + } catch { + throw new Error(`${label} returned invalid JSON`); + } +} + +function assertNoSecret(value, label) { + const text = Buffer.isBuffer(value) + ? value.toString("latin1") + : typeof value === "string" + ? value + : JSON.stringify(value); + const variants = Buffer.isBuffer(value) + ? [text, value.toString("utf8"), value.toString("base64")] + : [text]; + try { + variants.push(decodeURIComponent(text)); + } catch { + /* Not percent encoded. */ + } + try { + variants.push(Buffer.from(text, "base64").toString("utf8")); + } catch { + /* Not base64. */ + } + for (const candidate of variants) { + if (SYNTHETIC_KEY && candidate.includes(SYNTHETIC_KEY)) + throw new Error(`Fixture credential appeared in ${label}`); + if (SECRET_PATTERN.test(candidate)) throw new Error(`Credential-like value found in ${label}`); + SECRET_PATTERN.lastIndex = 0; + } +} + +async function getSession(page) { + await page.goto(`${BASE}/_emdash/api/setup/dev-bypass?redirect=/_emdash/api/auth/me`); + await page.waitForURL((url) => url.pathname === "/_emdash/api/auth/me", { timeout: 30_000 }); + const dismissed = await page.request.post(`${BASE}/_emdash/api/auth/me`, { + headers: { "X-EmDash-Request": "1" }, + data: { action: "dismissWelcome" }, + }); + if (!dismissed.ok()) + throw new Error(`Could not clear first-login welcome (${dismissed.status()})`); +} + +async function waitForStableChart(page) { + await page.waitForFunction( + () => { + const charts = [...document.querySelectorAll("canvas")].filter((canvas) => { + const rect = canvas.getBoundingClientRect(); + return rect.width >= 400 && rect.height >= 150; + }); + if (charts.length === 0) return false; + let pixels; + try { + pixels = charts.map((canvas) => canvas.toDataURL()).join(":"); + } catch { + return false; + } + const windowWithChartState = window; + const state = windowWithChartState.oaChartCapture; + if (!state || state.pixels !== pixels) { + windowWithChartState.oaChartCapture = { pixels, stableFrames: 0 }; + return false; + } + state.stableFrames += 1; + return state.stableFrames >= 90; + }, + undefined, + { timeout: 30_000, polling: "raf" }, + ); +} + +try { + if (new URL(BASE).hostname !== "127.0.0.1" || new URL(FIXTURE_BASE).hostname !== "127.0.0.1") + throw new Error("The screenshot demo must bind to loopback only."); + start(process.execPath, [join(ROOT, "scripts/demo-openanalytics.mjs")], "oa-fixture", { + OA_FIXTURE_PORT: String(OA_PORT), + OA_FIXTURE_RUN_ID: runId, + }); + await waitFor( + `${FIXTURE_BASE}/__health?run=${runId}`, + children[0], + "OpenAnalytics fixture", + true, + ); + start( + join(ROOT, "node_modules/.bin/astro"), + ["dev", "--no-background", "--host", "127.0.0.1", "--port", String(PORT)], + "emdash", + { + HOST: "127.0.0.1", + TZ: "America/New_York", + EMDASH_DEMO_DB_URL: `file:${join(staging, "demo.db")}`, + EMDASH_ENCRYPTION_KEY: "emdash_enc_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ASTRO_DEV_BACKGROUND: "1", + EMDASH_DEMO_PORT: String(PORT), + EMDASH_DEMO_RUN_ID: runId, + }, + ); + await waitFor(`${BASE}/demo-health?run=${runId}`, children[1], "EmDash", true); + + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 1100 }, + deviceScaleFactor: 1, + }); + const page = await context.newPage(); + const responseScans = []; + page.on("response", (response) => { + if (!response.url().startsWith(BASE)) return; + responseScans.push( + (async () => { + try { + const type = response.headers()["content-type"] ?? ""; + if (!/(json|text|javascript|xml|svg)/i.test(type)) return; + responseText.push(`${response.url()}: ${await response.text()}`); + } catch { + /* Request was cancelled or returned a non-text body. */ + } + })(), + ); + }); + await getSession(page); + const headers = { "X-EmDash-Request": "1", "Content-Type": "application/json", Origin: BASE }; + const settings = await page.request.put( + `${BASE}/_emdash/api/admin/plugins/emdash-openanalytics/settings`, + { + headers, + data: { + values: { + apiUrl: FIXTURE_BASE, + privateReadKey: SYNTHETIC_KEY, + trackingEnabled: true, + timezone: "America/New_York", + }, + }, + }, + ); + await requireOk(settings, "seed synthetic plugin settings"); + const validation = await page.request.post( + `${BASE}/_emdash/api/plugins/emdash-openanalytics/validate-connection`, + { + headers, + data: {}, + }, + ); + const validationJson = await requireOk(validation, "validate fixture connection"); + const validationResult = validationJson.data ?? validationJson; + if (validationResult.success !== true) + throw new Error("The fixture connection did not validate successfully."); + + const requested = []; + page.on("request", (request) => { + if (request.url().includes("/_emdash/api/plugins/emdash-openanalytics")) + requested.push(request.url()); + }); + await page.goto(`${BASE}/_emdash/admin/plugins/emdash-openanalytics/analytics`); + await page.locator("text=Top Pages").waitFor({ state: "visible", timeout: 30_000 }); + await page.locator("text=Traffic Sources").waitFor({ state: "visible", timeout: 30_000 }); + await page.getByText("1284", { exact: true }).waitFor({ state: "visible" }); + await page.getByText("EmDash Demo", { exact: true }).waitFor({ state: "visible" }); + await page.locator("canvas").first().waitFor({ state: "visible", timeout: 30_000 }); + await waitForStableChart(page); + await Promise.all(responseScans); + + // Inspect exactly the rendered page contents and all returned text before any PNG is retained. + const desktopText = await page.locator("body").innerText(); + const desktopHtml = await page.content(); + assertNoSecret(desktopText, "desktop rendered text"); + assertNoSecret(desktopHtml, "desktop page HTML"); + for (const item of responseText) assertNoSecret(item, "browser HTML/API response body"); + for (const line of childLogs) assertNoSecret(line, "demo process logs"); + const readLogResponse = await fetch(`${FIXTURE_BASE}/__requests`); + const readLog = await readLogResponse.json(); + const analyticsReads = readLog.filter((item) => item.path.startsWith("/v1/read/analytics/")); + const relevant = [ + "/v1/read/analytics/overview", + "/v1/read/analytics/timeseries", + "/v1/read/analytics/pages", + "/v1/read/analytics/sources", + ]; + for (const path of relevant) { + const entry = analyticsReads.find((item) => item.path === path); + if (!entry || !entry.from || !entry.to || entry.timezone !== "America/New_York") + throw new Error(`Expected range and timezone were not sent to ${path}.`); + if (path.endsWith("/pages") || path.endsWith("/sources")) { + if (entry.limit !== "10") throw new Error(`Expected fixed limit=10 on ${path}.`); + } + } + if (new Set(analyticsReads.map(({ from, to }) => `${from}|${to}`)).size !== 1) + throw new Error("Overview and report calls did not share one selected range."); + await mkdir(staging, { recursive: true }); + await waitForStableChart(page); + await page.screenshot({ path: join(staging, "openanalytics-overview.png"), fullPage: false }); + await page.getByText("Top Pages", { exact: true }).scrollIntoViewIfNeeded(); + await waitForStableChart(page); + await page.screenshot({ path: join(staging, "openanalytics-reports.png"), fullPage: false }); + + await page.setViewportSize({ width: 640, height: 1800 }); + await page.evaluate(() => { + delete window.oaChartCapture; + window.scrollTo(0, 0); + for (const element of document.querySelectorAll("*")) { + const style = getComputedStyle(element); + if ( + element.scrollHeight > element.clientHeight && + (style.overflowY === "auto" || style.overflowY === "scroll") + ) + element.scrollTop = 0; + } + }); + await page.getByRole("heading", { name: "OpenAnalytics" }).waitFor({ state: "visible" }); + await waitForStableChart(page); + await Promise.all(responseScans); + const narrowText = await page.locator("body").innerText(); + const narrowHtml = await page.content(); + assertNoSecret(narrowText, "narrow rendered text"); + assertNoSecret(narrowHtml, "narrow page HTML"); + await page.screenshot({ path: join(staging, "openanalytics-narrow.png"), fullPage: false }); + assertNoSecret( + await readFile(join(staging, "openanalytics-overview.png")), + "overview screenshot bytes", + ); + assertNoSecret( + await readFile(join(staging, "openanalytics-reports.png")), + "reports screenshot bytes", + ); + assertNoSecret( + await readFile(join(staging, "openanalytics-narrow.png")), + "narrow screenshot bytes", + ); + if (requested.length === 0) + throw new Error("The actual plugin admin route did not make an analytics request."); + await mkdir(SCREENSHOTS, { recursive: true }); + for (const name of [ + "openanalytics-overview.png", + "openanalytics-reports.png", + "openanalytics-narrow.png", + ]) + await writeFile(join(SCREENSHOTS, name), await readFile(join(staging, name))); + console.log(`Screenshots saved to ${SCREENSHOTS}`); +} catch (error) { + if (error instanceof Error) { + error.message = error.message.replace(SECRET_PATTERN, "[REDACTED]"); + if (error.stack) error.stack = error.stack.replace(SECRET_PATTERN, "[REDACTED]"); + // Keep upstream response bodies out of surfaced error causes. + // oxlint-disable-next-line preserve-caught-error -- Raw errors may contain credentials. + throw new Error(error.message); + } + // oxlint-disable-next-line preserve-caught-error -- Raw values may contain credentials. + throw new Error("Unknown screenshot failure"); +} finally { + if (browser) await browser.close().catch(() => {}); + for (const child of children) child.kill("SIGTERM"); + await Promise.all( + children.map( + (child) => + new Promise((resolveDone) => { + if (child.exitCode !== null || child.signalCode !== null) return resolveDone(); + child.once("exit", resolveDone); + setTimeout(() => { + child.kill("SIGKILL"); + resolveDone(); + }, 3000).unref(); + }), + ), + ); + await rm(staging, { recursive: true, force: true }); +} diff --git a/src/admin/connection.ts b/src/admin/connection.ts new file mode 100644 index 0000000..edc4c82 --- /dev/null +++ b/src/admin/connection.ts @@ -0,0 +1,128 @@ +import type { Block } from "@emdash-cms/blocks"; + +import { containsPrivateKey } from "../openanalytics/client"; +import { dateText, type DateRange } from "./ranges"; + +export function displayApiUrl(value: unknown): string { + if (typeof value !== "string" || !value.trim()) return "Not configured"; + if (containsPrivateKey(value)) return "Invalid API URL"; + try { + const parsed = new URL(value.trim()); + if ( + parsed.username || + parsed.password || + parsed.search || + parsed.hash || + !["http:", "https:"].includes(parsed.protocol) + ) + return "Invalid API URL"; + return parsed.toString().replace(/\/$/, ""); + } catch { + return "Invalid API URL"; + } +} + +export function connectionBlocks(args: { + apiUrl: string; + site?: { + name: string; + status: string; + install: { hasTrackingKey: boolean; trackerReady: boolean }; + }; + trackingEnabled: boolean; + validatedAt?: string; + needsValidation?: boolean; + notConfigured?: boolean; + error?: string; +}): Block[] { + let title = args.notConfigured + ? "Not configured" + : args.needsValidation + ? "Needs validation" + : "Connected"; + let description = args.notConfigured + ? "Add an OpenAnalytics private read key in plugin settings to connect this site." + : args.needsValidation + ? "Validate your private read key to connect this EmDash site." + : "Last validation confirmed this connection."; + let variant: "default" | "alert" | "error" = + args.notConfigured || args.needsValidation ? "alert" : "default"; + if (args.error) { + title = "Connection needs attention"; + description = args.error; + variant = "error"; + } else if (args.site && args.site.status !== "active") { + title = `Connected · site ${args.site.status}`; + description = "OpenAnalytics reports that this site is not active."; + variant = "alert"; + } + const install = args.site?.install; + const tracking = !install?.hasTrackingKey + ? "No tracking key" + : !install.trackerReady + ? "Tracking installation incomplete" + : !args.trackingEnabled + ? "Tracking disabled" + : args.site?.status === "active" + ? "Tracking active" + : args.site?.status === "suspended" + ? "Tracker installed · collection suspended" + : "Tracker installed · collection unavailable"; + return [ + { type: "banner", title, description, variant }, + { + type: "fields", + fields: [ + { label: "Site", value: args.site?.name ?? "—" }, + { label: "Tracking", value: args.site ? tracking : "Not validated" }, + { label: "API", value: args.apiUrl }, + { + label: "Last validated", + value: args.site + ? (dateText(args.validatedAt ?? null, "UTC") ?? "Not recorded") + : "Never", + }, + ], + }, + ]; +} + +export function controls( + selected: DateRange, + options: { validate?: boolean; retry?: boolean } = {}, +): Block { + const labels: Record = { + "24h": "Last 24 hours", + "7d": "Last 7 days", + "30d": "Last 30 days", + "90d": "Last 90 days", + }; + const elements: Extract["elements"] = [ + { + type: "select", + action_id: "range", + label: "Date range", + initial_value: selected, + options: (["24h", "7d", "30d", "90d"] as const).map((value) => ({ + label: labels[value], + value, + })), + }, + ]; + if (options.retry) + elements.push({ + type: "button", + action_id: "retry", + label: "Retry analytics", + style: "primary", + value: { range: selected }, + }); + elements.push({ + type: "button", + action_id: "revalidate", + label: options.validate ? "Validate connection" : "Revalidate connection", + style: "secondary", + value: { range: selected }, + }); + return { type: "actions", elements }; +} diff --git a/src/admin/overview.ts b/src/admin/overview.ts new file mode 100644 index 0000000..3e34e0d --- /dev/null +++ b/src/admin/overview.ts @@ -0,0 +1,141 @@ +import type { Block } from "@emdash-cms/blocks"; + +import type { + AnalyticsOverviewResponse, + AnalyticsTimeseriesResponse, +} from "../openanalytics/types"; +import { dateText, rangeText } from "./ranges"; + +const STATUS = { + ok: "current", + no_data: "no data", + stale: "delayed", + degraded: "status unavailable", +} as const; + +export function renderOverview(args: { + timezone: string; + overview?: AnalyticsOverviewResponse; + timeseries?: AnalyticsTimeseriesResponse; + overviewError?: string; + timeseriesError?: string; +}): Block[] { + const blocks: Block[] = []; + if (args.overview) { + const overview = args.overview; + blocks.push({ + type: "stats", + items: (["visitors", "pageviews", "events"] as const).map((metric) => ({ + label: { visitors: "Visitors", pageviews: "Pageviews", events: "Events" }[metric], + value: overview.totals[metric], + ...(overview.comparison + ? { description: `Previous period: ${overview.comparison.totals[metric]}` } + : {}), + })), + }); + const freshness = args.overview.meta.freshness; + const watermark = freshness ? dateText(freshness.watermark, args.timezone) : null; + blocks.push({ + type: "context", + text: watermark + ? `Latest rolled-up data: ${watermark}.` + : "Latest rolled-up data timestamp is unavailable.", + }); + blocks.push({ + type: "context", + text: `Totals cover ${rangeText(args.overview.meta.effective_range, args.timezone)}.`, + }); + if (args.overview.comparison && args.overview.meta.comparison_range) { + blocks.push({ + type: "context", + text: `Previous period: ${rangeText(args.overview.meta.comparison_range, args.timezone)}.`, + }); + } + if (freshness) + blocks.push({ type: "context", text: `Data status: totals ${STATUS[freshness.state]}.` }); + const warning = metaWarning(args.overview.meta); + if (warning) + blocks.push({ + type: "banner", + title: "Overview data status", + description: warning, + variant: "alert", + }); + } else if (args.overviewError) { + blocks.push({ + type: "banner", + title: "Overview unavailable", + description: args.overviewError, + variant: "error", + }); + } + + if (args.timeseries) { + blocks.push({ + type: "chart", + config: { + chart_type: "timeseries", + style: "line", + x_axis_name: "Time", + y_axis_name: "Count", + series: [ + { + name: "Visitors", + data: args.timeseries.series.map((p) => [Date.parse(p.bucket), p.visitors]), + }, + { + name: "Pageviews", + data: args.timeseries.series.map((p) => [Date.parse(p.bucket), p.pageviews]), + }, + ], + }, + }); + blocks.push({ + type: "context", + text: `Chart covers ${rangeText(args.timeseries.meta.effective_range, args.timezone)}. Buckets use ${args.timezone}; timestamps are displayed in the browser timezone.`, + }); + if (args.timeseries.meta.freshness) + blocks.push({ + type: "context", + text: `Data status: chart ${STATUS[args.timeseries.meta.freshness.state]}.`, + }); + const warning = metaWarning(args.timeseries.meta); + if (warning) + blocks.push({ + type: "banner", + title: "Chart data status", + description: warning, + variant: "alert", + }); + } else if (args.timeseriesError) { + blocks.push({ + type: "banner", + title: "Chart unavailable", + description: args.timeseriesError, + variant: "error", + }); + } + return blocks; +} + +function metaWarning(meta: AnalyticsOverviewResponse["meta"]): string | null { + if (meta.freshness?.state === "no_data") + return "No analytics data is available for part or all of this range."; + const delayed = + meta.freshness?.state === "stale" || + meta.freshness?.state === "degraded" || + meta.partial || + meta.truncated; + const imported = meta.data_sources.includes("imported"); + const estimated = meta.accuracy !== "exact"; + if (delayed && imported) + return "Some imported data may be delayed or incomplete while OpenAnalytics finishes processing."; + if (delayed) + return "Results may be delayed or incomplete while OpenAnalytics finishes processing events."; + if (imported && estimated) + return "This range includes imported analytics. Some values are estimated or follow the import provider's definitions."; + if (imported) return "This range includes imported analytics data."; + if (estimated) return "OpenAnalytics marks some values as estimated or provider-defined."; + if (meta.freshness === null) return "Freshness information is unavailable for this response."; + return null; +} diff --git a/src/admin/page.ts b/src/admin/page.ts index d8d4baa..18e1cfd 100644 --- a/src/admin/page.ts +++ b/src/admin/page.ts @@ -10,17 +10,15 @@ import { type SiteSnapshot, validateConnection, } from "../connection"; -import { containsPrivateKey, getOverview, getTimeseries } from "../openanalytics/client"; +import { getOverview, getPages, getSources, getTimeseries } from "../openanalytics/client"; import { OpenAnalyticsError } from "../openanalytics/errors"; -import type { - AnalyticsReadQuery, - AnalyticsOverviewResponse, - AnalyticsTimeseriesResponse, -} from "../openanalytics/types"; +import type { AnalyticsReadQuery } from "../openanalytics/types"; import { DEFAULT_API_URL, type OpenAnalyticsConfig } from "../settings/config"; +import { connectionBlocks, controls, displayApiUrl } from "./connection"; +import { renderOverview } from "./overview"; +import { queryFor, rangeLabel, RANGES, selectedRange, type DateRange } from "./ranges"; +import { renderPages, renderSources } from "./reports"; -const RANGES = ["24h", "7d", "30d", "90d"] as const; -type DateRange = (typeof RANGES)[number]; type Input = | { type: "page_load"; page: string } | { type: "block_action"; action_id: string; value?: unknown; page?: string }; @@ -42,56 +40,6 @@ function parseInput(value: unknown): Input | null { return null; } -function range(value: unknown): DateRange { - if (typeof value === "string" && RANGES.includes(value as DateRange)) return value as DateRange; - if (record(value) && typeof value.range === "string" && RANGES.includes(value.range as DateRange)) - return value.range as DateRange; - return "30d"; -} - -function displayApiUrl(value: unknown): string { - if (typeof value !== "string" || !value.trim()) return "Not configured"; - if (containsPrivateKey(value)) return "Invalid API URL"; - try { - const parsed = new URL(value.trim()); - if ( - parsed.username || - parsed.password || - parsed.search || - parsed.hash || - !["http:", "https:"].includes(parsed.protocol) - ) - return "Invalid API URL"; - return parsed.toString().replace(/\/$/, ""); - } catch { - return "Invalid API URL"; - } -} - -function queryFor(selected: DateRange, tz: string, now = Date.now()): AnalyticsReadQuery { - const ms: Record = { - "24h": 24 * 60 * 60 * 1000, - "7d": 7 * 24 * 60 * 60 * 1000, - "30d": 30 * 24 * 60 * 60 * 1000, - "90d": 90 * 24 * 60 * 60 * 1000, - }; - return { - from: new Date(now - ms[selected]).toISOString(), - to: new Date(now).toISOString(), - timezone: tz, - resolution: selected === "24h" ? "hour" : "day", - }; -} - -function label(selected: DateRange): string { - return { - "24h": "Last 24 hours", - "7d": "Last 7 days", - "30d": "Last 30 days", - "90d": "Last 90 days", - }[selected]; -} - function safeError(kind: unknown, retryAfterSeconds?: number): string { switch (kind) { case "analytics_forbidden": @@ -127,122 +75,6 @@ function errorCopy(error: unknown): string { : "OpenAnalytics is temporarily unavailable. Try again shortly."; } -function dateText(value: string | null, tz: string): string | null { - if (!value || !Number.isFinite(Date.parse(value))) return null; - return new Intl.DateTimeFormat(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - timeZoneName: "short", - timeZone: tz, - }).format(new Date(value)); -} - -function rangeText(value: { from: string; to: string }, tz: string): string { - const from = dateText(value.from, tz); - const to = dateText(value.to, tz); - return from && to ? `${from} – ${to}` : "unavailable"; -} - -function connectionBlocks(args: { - apiUrl: string; - site?: { - name: string; - status: string; - install: { hasTrackingKey: boolean; trackerReady: boolean }; - }; - trackingEnabled: boolean; - validatedAt?: string; - needsValidation?: boolean; - notConfigured?: boolean; - error?: string; -}): Block[] { - let title = args.notConfigured - ? "Not configured" - : args.needsValidation - ? "Needs validation" - : "Connected"; - let description = args.notConfigured - ? "Add an OpenAnalytics private read key in plugin settings to connect this site." - : args.needsValidation - ? "Validate your private read key to connect this EmDash site." - : "Last validation confirmed this connection."; - let variant: "default" | "alert" | "error" = - args.notConfigured || args.needsValidation ? "alert" : "default"; - if (args.error) { - title = "Connection needs attention"; - description = args.error; - variant = "error"; - } else if (args.site && args.site.status !== "active") { - title = `Connected · site ${args.site.status}`; - description = "OpenAnalytics reports that this site is not active."; - variant = "alert"; - } - const install = args.site?.install; - const tracking = !install?.hasTrackingKey - ? "No tracking key" - : !install.trackerReady - ? "Tracking installation incomplete" - : !args.trackingEnabled - ? "Tracking disabled" - : args.site?.status === "active" - ? "Tracking active" - : "Tracking inactive"; - return [ - { type: "banner", title, description, variant }, - { - type: "fields", - fields: [ - { label: "Site", value: args.site?.name ?? "—" }, - { label: "Tracking", value: args.site ? tracking : "Not validated" }, - { label: "API", value: args.apiUrl }, - { - label: "Last validated", - value: args.site - ? (dateText(args.validatedAt ?? null, "UTC") ?? "Not recorded") - : "Never", - }, - ], - }, - ]; -} - -function controls( - selected: DateRange, - options: { validate?: boolean; retry?: boolean } = {}, -): Block { - const elements: Extract["elements"] = [ - { - type: "select", - action_id: "range", - label: "Date range", - initial_value: selected, - options: RANGES.map((value) => ({ label: label(value), value })), - }, - ]; - if (options.retry) - elements.push({ - type: "button", - action_id: "retry", - label: "Retry analytics", - style: "primary", - value: { range: selected }, - }); - elements.push({ - type: "button", - action_id: "revalidate", - label: options.validate ? "Validate connection" : "Revalidate connection", - style: "secondary", - value: { range: selected }, - }); - return { - type: "actions", - elements, - }; -} - function waitingPage( selected: DateRange, apiUrl: string, @@ -269,72 +101,48 @@ function waitingPage( }; } -function getMetaWarning( - response: AnalyticsOverviewResponse, - timeseries: AnalyticsTimeseriesResponse, -): string | null { - const metas = [response.meta, timeseries.meta]; - const unavailable = metas.some((meta) => meta.freshness === null); - const delayed = metas.some( - (meta) => - meta.freshness?.state === "stale" || - meta.freshness?.state === "degraded" || - meta.partial || - meta.truncated, - ); - const imported = metas.some((meta) => meta.data_sources.includes("imported")); - const estimated = metas.some((meta) => meta.accuracy !== "exact"); - if (metas.some((meta) => meta.freshness?.state === "no_data")) - return "No analytics data is available for part or all of this range."; - if (delayed && imported) - return "Some imported data may be delayed or incomplete while OpenAnalytics finishes processing."; - if (delayed) - return "Results may be delayed or incomplete while OpenAnalytics finishes processing events."; - if (imported && estimated) - return "This range includes imported analytics. Some values are estimated or follow the import provider's definitions."; - if (imported) return "This range includes imported analytics data."; - if (estimated) return "OpenAnalytics marks some values as estimated or provider-defined."; - if (unavailable) return "Freshness information is unavailable for part of this response."; - return null; +function timezoneFromSetting(value: unknown): { timezone: string; error: string | null } { + const raw = typeof value === "string" ? value.trim() : ""; + if (!raw) return { timezone: "UTC", error: null }; + try { + new Intl.DateTimeFormat("en", { timeZone: raw }).format(0); + return { timezone: raw, error: null }; + } catch { + return { + timezone: "UTC", + error: + "Analytics timezone is invalid. Set an IANA timezone such as America/New_York in plugin settings.", + }; + } } async function loadAnalytics( config: OpenAnalyticsConfig, snapshot: SiteSnapshot, selected: DateRange, - tz: string, + timezone: string, trackingEnabled: boolean, ): Promise { const site = safeConnectionSummary(snapshot.site); - const query = queryFor(selected, tz); - const overviewQuery: AnalyticsReadQuery = { ...query, resolution: "hour", compare: true }; - let overview: AnalyticsOverviewResponse; - let timeseries: AnalyticsTimeseriesResponse; - try { - [overview, timeseries] = await Promise.all([ - getOverview(config, overviewQuery), - getTimeseries(config, query), - ]); - } catch (error) { - return { - blocks: [ - { type: "header", text: "OpenAnalytics" }, - ...connectionBlocks({ - apiUrl: config.apiUrl, - site, - trackingEnabled, - validatedAt: snapshot.validatedAt, - }), - controls(selected, { retry: true }), - { - type: "banner", - title: "Analytics unavailable", - description: errorCopy(error), - variant: "error", - }, - ], - }; - } + const bounds = queryFor(selected, timezone); + const overviewQuery: AnalyticsReadQuery = { ...bounds, resolution: "hour", compare: true }; + const chartQuery: AnalyticsReadQuery = { + ...bounds, + resolution: selected === "24h" ? "hour" : "day", + }; + const reportQuery = { from: bounds.from, to: bounds.to, timezone, limit: 10 }; + const results = await Promise.allSettled([ + getOverview(config, overviewQuery), + getTimeseries(config, chartQuery), + getPages(config, reportQuery), + getSources(config, reportQuery), + ]); + const [overviewResult, chartResult, pagesResult, sourcesResult] = results; + const overview = overviewResult.status === "fulfilled" ? overviewResult.value : undefined; + const timeseries = chartResult.status === "fulfilled" ? chartResult.value : undefined; + const pages = pagesResult.status === "fulfilled" ? pagesResult.value : undefined; + const sources = sourcesResult.status === "fulfilled" ? sourcesResult.value : undefined; + const failures = results.some((result) => result.status === "rejected"); const blocks: Block[] = [ { type: "header", text: "OpenAnalytics" }, ...connectionBlocks({ @@ -343,79 +151,26 @@ async function loadAnalytics( trackingEnabled, validatedAt: snapshot.validatedAt, }), - controls(selected), - { type: "header", text: label(selected) }, - { - type: "context", - text: `Buckets use ${tz}; chart timestamps are displayed in the browser timezone.`, - }, - { - type: "stats", - items: (["visitors", "pageviews", "events"] as const).map((metric) => ({ - label: { visitors: "Visitors", pageviews: "Pageviews", events: "Events" }[metric], - value: overview.totals[metric], - ...(overview.comparison - ? { description: `Previous period: ${overview.comparison.totals[metric]}` } - : {}), - })), - }, - { - type: "chart", - config: { - chart_type: "timeseries", - style: "line", - x_axis_name: "Time", - y_axis_name: "Count", - series: [ - { - name: "Visitors", - data: timeseries.series.map( - (p) => [Date.parse(p.bucket), p.visitors] as [number, number], - ), - }, - { - name: "Pageviews", - data: timeseries.series.map( - (p) => [Date.parse(p.bucket), p.pageviews] as [number, number], - ), - }, - ], - }, - }, + controls(selected, { retry: failures }), + { type: "header", text: rangeLabel(selected) }, + ...renderOverview({ + timezone, + overview, + timeseries, + overviewError: + overviewResult.status === "rejected" ? errorCopy(overviewResult.reason) : undefined, + timeseriesError: + chartResult.status === "rejected" ? errorCopy(chartResult.reason) : undefined, + }), + ...renderPages( + pages, + pagesResult.status === "rejected" ? errorCopy(pagesResult.reason) : undefined, + ), + ...renderSources( + sources, + sourcesResult.status === "rejected" ? errorCopy(sourcesResult.reason) : undefined, + ), ]; - const freshness = overview.meta.freshness; - const watermark = freshness ? dateText(freshness.watermark, tz) : null; - blocks.push({ - type: "context", - text: watermark - ? `Latest rolled-up data: ${watermark}.` - : "Latest rolled-up data timestamp is unavailable.", - }); - blocks.push({ - type: "context", - text: `Totals cover ${rangeText(overview.meta.effective_range, tz)}. Chart covers ${rangeText(timeseries.meta.effective_range, tz)}.`, - }); - if (overview.comparison && overview.meta.comparison_range) { - blocks.push({ - type: "context", - text: `Previous period: ${rangeText(overview.meta.comparison_range, tz)}.`, - }); - } - if (overview.meta.freshness && timeseries.meta.freshness) { - const states = { - ok: "current", - no_data: "no data", - stale: "delayed", - degraded: "status unavailable", - }; - blocks.push({ - type: "context", - text: `Data status: totals ${states[overview.meta.freshness.state]}; chart ${states[timeseries.meta.freshness.state]}.`, - }); - } - const warning = getMetaWarning(overview, timeseries); - if (warning) - blocks.push({ type: "banner", title: "Data status", description: warning, variant: "alert" }); return { blocks }; } @@ -436,7 +191,7 @@ export async function renderAdminPage(ctx: RouteContext): Promise interaction.type === "block_action" && interaction.action_id === "range" && (typeof interaction.value !== "string" || !RANGES.includes(interaction.value as DateRange)) - ) { + ) return { blocks: [ { type: "header", text: "OpenAnalytics" }, @@ -448,8 +203,7 @@ export async function renderAdminPage(ctx: RouteContext): Promise }, ], }; - } - const selected = interaction.type === "block_action" ? range(interaction.value) : "30d"; + const selected = interaction.type === "block_action" ? selectedRange(interaction.value) : "30d"; const [apiUrlValue, key, trackingValue, timezoneValue] = await Promise.all([ ctx.settings.get("apiUrl"), ctx.settings.get("privateReadKey"), @@ -473,18 +227,7 @@ export async function renderAdminPage(ctx: RouteContext): Promise !configured, ); } - const rawTimezone = typeof timezoneValue === "string" ? timezoneValue.trim() : ""; - let tz = "UTC"; - let timezoneError: string | null = null; - if (rawTimezone) { - try { - new Intl.DateTimeFormat("en", { timeZone: rawTimezone }).format(0); - tz = rawTimezone; - } catch { - timezoneError = - "Analytics timezone is invalid. Set an IANA timezone such as America/New_York in plugin settings."; - } - } + const { timezone, error: timezoneError } = timezoneFromSetting(timezoneValue); let validationError: string | null = null; if (interaction.type === "block_action" && interaction.action_id === "revalidate") { const validation = await validateConnection(ctx); @@ -499,13 +242,12 @@ export async function renderAdminPage(ctx: RouteContext): Promise const snapshot = await ctx.kv.get(SITE_SNAPSHOT_KEY); if (validationError) { if (isSiteSnapshot(snapshot) && snapshot.fingerprint === fingerprint) { - const site = safeConnectionSummary(snapshot.site); return { blocks: [ { type: "header", text: "OpenAnalytics" }, ...connectionBlocks({ apiUrl: config.apiUrl, - site, + site: safeConnectionSummary(snapshot.site), trackingEnabled, validatedAt: snapshot.validatedAt, }), @@ -522,36 +264,36 @@ export async function renderAdminPage(ctx: RouteContext): Promise return waitingPage(selected, config.apiUrl, trackingEnabled, validationError, false); } if (timezoneError) { - const blocks: Block[] = [ - { type: "header", text: "OpenAnalytics" }, - ...(isSiteSnapshot(snapshot) && snapshot.fingerprint === fingerprint - ? connectionBlocks({ - apiUrl: config.apiUrl, - site: safeConnectionSummary(snapshot.site), - trackingEnabled, - validatedAt: snapshot.validatedAt, - }) - : connectionBlocks({ apiUrl: config.apiUrl, trackingEnabled, needsValidation: true })), - controls(selected), - { - type: "banner", - title: "Check analytics settings", - description: timezoneError, - variant: "error", - }, - ]; - return { blocks }; + return { + blocks: [ + { type: "header", text: "OpenAnalytics" }, + ...(isSiteSnapshot(snapshot) && snapshot.fingerprint === fingerprint + ? connectionBlocks({ + apiUrl: config.apiUrl, + site: safeConnectionSummary(snapshot.site), + trackingEnabled, + validatedAt: snapshot.validatedAt, + }) + : connectionBlocks({ apiUrl: config.apiUrl, trackingEnabled, needsValidation: true })), + controls(selected), + { + type: "banner", + title: "Check analytics settings", + description: timezoneError, + variant: "error", + }, + ], + }; } if (!isSiteSnapshot(snapshot) || snapshot.fingerprint !== fingerprint) { - const stale = isSiteSnapshot(snapshot); return waitingPage( selected, config.apiUrl, trackingEnabled, - stale + isSiteSnapshot(snapshot) ? "Configuration changed. Revalidate the connection to resume tracking and load analytics." : "Validate the connection to load analytics.", ); } - return loadAnalytics(config, snapshot, selected, tz, trackingEnabled); + return loadAnalytics(config, snapshot, selected, timezone, trackingEnabled); } diff --git a/src/admin/ranges.ts b/src/admin/ranges.ts new file mode 100644 index 0000000..e32b18d --- /dev/null +++ b/src/admin/ranges.ts @@ -0,0 +1,64 @@ +import type { AnalyticsReadQuery } from "../openanalytics/types"; + +export const RANGES = ["24h", "7d", "30d", "90d"] as const; +export type DateRange = (typeof RANGES)[number]; + +export function selectedRange(value: unknown): DateRange { + if (typeof value === "string" && RANGES.includes(value as DateRange)) return value as DateRange; + if ( + value && + typeof value === "object" && + "range" in value && + typeof value.range === "string" && + RANGES.includes(value.range as DateRange) + ) + return value.range as DateRange; + return "30d"; +} + +export function rangeLabel(selected: DateRange): string { + return { + "24h": "Last 24 hours", + "7d": "Last 7 days", + "30d": "Last 30 days", + "90d": "Last 90 days", + }[selected]; +} + +export function queryFor( + selected: DateRange, + timezone: string, + now = Date.now(), +): AnalyticsReadQuery { + const duration: Record = { + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, + "90d": 90 * 24 * 60 * 60 * 1000, + }; + return { + from: new Date(now - duration[selected]).toISOString(), + to: new Date(now).toISOString(), + timezone, + resolution: selected === "24h" ? "hour" : "day", + }; +} + +export function dateText(value: string | null, timezone: string): string | null { + if (!value || !Number.isFinite(Date.parse(value))) return null; + return new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + timeZone: timezone, + }).format(new Date(value)); +} + +export function rangeText(value: { from: string; to: string }, timezone: string): string { + const from = dateText(value.from, timezone); + const to = dateText(value.to, timezone); + return from && to ? `${from} – ${to}` : "unavailable"; +} diff --git a/src/admin/reports.ts b/src/admin/reports.ts new file mode 100644 index 0000000..3947205 --- /dev/null +++ b/src/admin/reports.ts @@ -0,0 +1,80 @@ +import type { Block } from "@emdash-cms/blocks"; + +import type { AnalyticsPagesResponse, AnalyticsSourcesResponse } from "../openanalytics/types"; + +function reportWarning(meta: AnalyticsPagesResponse["meta"]): string | null { + const notices: string[] = []; + if (meta.freshness?.state === "stale" || meta.freshness?.state === "degraded") + notices.push("data may be delayed"); + if (meta.partial || meta.truncated) notices.push("results may be incomplete"); + if (meta.data_sources.includes("imported")) notices.push("includes imported data"); + if (meta.accuracy !== "exact") notices.push("some values are estimated or provider-defined"); + if (meta.freshness === null) notices.push("freshness information is unavailable"); + return notices.length ? `Report status: ${notices.join("; ")}.` : null; +} + +export function renderPages(response?: AnalyticsPagesResponse, error?: string): Block[] { + const blocks: Block[] = [{ type: "header", text: "Top Pages" }]; + if (error) { + blocks.push({ type: "context", text: `Top pages temporarily unavailable. ${error}` }); + return blocks; + } + if (!response) return blocks; + blocks.push({ + type: "table", + page_action_id: "unused-pages-pagination", + empty_text: "No page activity in this range.", + columns: [ + { key: "page", label: "Page", format: "code" }, + { key: "views", label: "Views", format: "number" }, + { key: "visitors", label: "Visitors", format: "number" }, + ], + rows: response.items.map((row) => ({ + page: row.page_path, + views: row.views, + visitors: row.visitors, + })), + }); + const warning = reportWarning(response.meta); + if (warning) blocks.push({ type: "context", text: warning }); + return blocks; +} + +function sourceLabel(row: AnalyticsSourcesResponse["items"][number]): string { + const hasUtm = row.utm_source !== "" || row.utm_medium !== "" || row.utm_campaign !== ""; + const primary = + row.referrer_domain || row.utm_source || (hasUtm ? "No referrer" : "Direct / internal"); + const dimensions = [ + row.utm_source && row.utm_source !== primary ? `source: ${row.utm_source}` : "", + row.utm_medium ? `medium: ${row.utm_medium}` : "", + row.utm_campaign ? `campaign: ${row.utm_campaign}` : "", + ].filter(Boolean); + return dimensions.length ? `${primary} · ${dimensions.join(" · ")}` : primary; +} + +export function renderSources(response?: AnalyticsSourcesResponse, error?: string): Block[] { + const blocks: Block[] = [{ type: "header", text: "Traffic Sources" }]; + if (error) { + blocks.push({ type: "context", text: `Traffic sources temporarily unavailable. ${error}` }); + return blocks; + } + if (!response) return blocks; + blocks.push({ + type: "table", + page_action_id: "unused-sources-pagination", + empty_text: "No traffic sources recorded in this range.", + columns: [ + { key: "source", label: "Source", format: "text" }, + { key: "views", label: "Views", format: "number" }, + { key: "visitors", label: "Visitors", format: "number" }, + ], + rows: response.items.map((row) => ({ + source: sourceLabel(row), + views: row.views, + visitors: row.visitors, + })), + }); + const warning = reportWarning(response.meta); + if (warning) blocks.push({ type: "context", text: warning }); + return blocks; +} diff --git a/src/openanalytics/client.ts b/src/openanalytics/client.ts index 4742db0..2ea3787 100644 --- a/src/openanalytics/client.ts +++ b/src/openanalytics/client.ts @@ -6,6 +6,11 @@ import type { AnalyticsMeta, AnalyticsOverviewResponse, AnalyticsReadQuery, + AnalyticsReportQuery, + AnalyticsPageRow, + AnalyticsPagesResponse, + AnalyticsSourceRow, + AnalyticsSourcesResponse, AnalyticsTimeseriesResponse, OverviewTotals, SiteReadContext, @@ -194,13 +199,29 @@ function isAnalyticsReadQuery(value: AnalyticsReadQuery): boolean { return true; } +function isAnalyticsReportQuery(value: AnalyticsReportQuery): boolean { + return ( + isUtcInstant(value.from) && + isUtcInstant(value.to) && + Date.parse(value.from) < Date.parse(value.to) && + isTimezone(value.timezone) && + (value.limit === undefined || + (Number.isInteger(value.limit) && value.limit >= 1 && value.limit <= 500)) + ); +} + +type AnalyticsRequestQuery = AnalyticsReadQuery | AnalyticsReportQuery; + async function getJSON( config: OpenAnalyticsConfig, path: string, - query?: AnalyticsReadQuery, + query?: AnalyticsRequestQuery, ): Promise { const validated = parseConfiguration(config); - if (query && !isAnalyticsReadQuery(query)) { + if ( + query && + !("resolution" in query ? isAnalyticsReadQuery(query) : isAnalyticsReportQuery(query)) + ) { throw new OpenAnalyticsError("configuration", "OpenAnalytics analytics query is invalid."); } const controller = new AbortController(); @@ -211,8 +232,13 @@ async function getJSON( url.searchParams.set("from", query.from); url.searchParams.set("to", query.to); url.searchParams.set("timezone", query.timezone); - url.searchParams.set("resolution", query.resolution); - if (query.compare !== undefined) url.searchParams.set("compare", String(query.compare)); + if ("resolution" in query) { + url.searchParams.set("resolution", query.resolution); + if (query.compare !== undefined) url.searchParams.set("compare", String(query.compare)); + } else { + url.searchParams.set("limit", String(query.limit ?? 10)); + if (path.endsWith("/pages")) url.searchParams.set("sort", "views"); + } } const response = await fetch(url.toString(), { method: "GET", @@ -357,3 +383,99 @@ export async function getTimeseries( if (containsPrivateKey(result)) throw new OpenAnalyticsError("invalid_response"); return result; } + +function isPageRow(value: unknown): value is AnalyticsPageRow { + return ( + isRecord(value) && + typeof value.page_path === "string" && + isCount(value.views) && + isCount(value.visitors) && + (value.entrances === null || isCount(value.entrances)) && + (value.exits === null || isCount(value.exits)) && + (value.bounces === null || isCount(value.bounces)) && + (value.bounce_rate === null || + (typeof value.bounce_rate === "number" && + Number.isFinite(value.bounce_rate) && + value.bounce_rate >= 0 && + value.bounce_rate <= 1)) + ); +} + +function isSourceRow(value: unknown): value is AnalyticsSourceRow { + return ( + isRecord(value) && + typeof value.referrer_domain === "string" && + typeof value.utm_source === "string" && + typeof value.utm_medium === "string" && + typeof value.utm_campaign === "string" && + isCount(value.views) && + isCount(value.visitors) + ); +} + +function isReportResponse( + value: unknown, + isRow: (row: unknown) => row is T, +): value is { meta: AnalyticsMeta; items: T[] } { + return ( + isRecord(value) && + isMeta(value.meta) && + isFreshness(value.meta.freshness) && + value.meta.comparison_range === null && + Array.isArray(value.items) && + value.items.every(isRow) + ); +} + +/** Read top pages, ranked by views, with session measures when available. */ +export async function getPages( + config: OpenAnalyticsConfig, + query: AnalyticsReportQuery, +): Promise { + const payload = await getJSON(config, "/v1/read/analytics/pages", query); + if (!isReportResponse(payload, isPageRow)) throw new OpenAnalyticsError("invalid_response"); + const result: AnalyticsPagesResponse = Object.freeze({ + meta: projectMeta(payload.meta), + items: Object.freeze( + payload.items.map((row) => + Object.freeze({ + page_path: row.page_path, + views: row.views, + visitors: row.visitors, + entrances: row.entrances, + exits: row.exits, + bounces: row.bounces, + bounce_rate: row.bounce_rate, + }), + ), + ), + }); + if (containsPrivateKey(result)) throw new OpenAnalyticsError("invalid_response"); + return result; +} + +/** Read acquisition rows by referrer domain and UTM tuple, ranked by views. */ +export async function getSources( + config: OpenAnalyticsConfig, + query: AnalyticsReportQuery, +): Promise { + const payload = await getJSON(config, "/v1/read/analytics/sources", query); + if (!isReportResponse(payload, isSourceRow)) throw new OpenAnalyticsError("invalid_response"); + const result: AnalyticsSourcesResponse = Object.freeze({ + meta: projectMeta(payload.meta), + items: Object.freeze( + payload.items.map((row) => + Object.freeze({ + referrer_domain: row.referrer_domain, + utm_source: row.utm_source, + utm_medium: row.utm_medium, + utm_campaign: row.utm_campaign, + views: row.views, + visitors: row.visitors, + }), + ), + ), + }); + if (containsPrivateKey(result)) throw new OpenAnalyticsError("invalid_response"); + return result; +} diff --git a/src/openanalytics/types.ts b/src/openanalytics/types.ts index fbf906d..7299651 100644 --- a/src/openanalytics/types.ts +++ b/src/openanalytics/types.ts @@ -28,6 +28,12 @@ export interface AnalyticsReadQuery extends AnalyticsDateRange { readonly compare?: boolean; } +/** The query shared by the top-N reports; their API has no resolution option. */ +export interface AnalyticsReportQuery extends AnalyticsDateRange { + readonly timezone: string; + readonly limit?: number; +} + export interface AnalyticsFreshness { readonly state: AnalyticsFreshnessState; readonly watermark: string | null; @@ -74,3 +80,32 @@ export interface AnalyticsTimeseriesResponse { readonly series: readonly TimeseriesPoint[]; readonly comparison: { readonly series: readonly TimeseriesPoint[] } | null; } + +export interface AnalyticsPageRow { + readonly page_path: string; + readonly views: number; + readonly visitors: number; + readonly entrances: number | null; + readonly exits: number | null; + readonly bounces: number | null; + readonly bounce_rate: number | null; +} + +export interface AnalyticsSourceRow { + readonly referrer_domain: string; + readonly utm_source: string; + readonly utm_medium: string; + readonly utm_campaign: string; + readonly views: number; + readonly visitors: number; +} + +export interface AnalyticsPagesResponse { + readonly meta: AnalyticsMeta; + readonly items: readonly AnalyticsPageRow[]; +} + +export interface AnalyticsSourcesResponse { + readonly meta: AnalyticsMeta; + readonly items: readonly AnalyticsSourceRow[]; +} diff --git a/tests/admin.test.ts b/tests/admin.test.ts index f87d1a7..3c0899f 100644 --- a/tests/admin.test.ts +++ b/tests/admin.test.ts @@ -125,9 +125,19 @@ function installFetch( site?: unknown; analyticsStatus?: number; analyticsErrorCode?: string; + analyticsFailurePath?: string; siteStatus?: number; missingFreshness?: boolean; freshnessState?: string; + emptyReports?: boolean; + sourceRows?: Array<{ + referrer_domain: string; + utm_source: string; + utm_medium: string; + utm_campaign: string; + views: number; + visitors: number; + }>; } = {}, ) { const requests: Array<{ url: string; init: RequestInit | undefined }> = []; @@ -168,7 +178,11 @@ function installFetch( headers: { "content-type": "application/json" }, }); } - if (options.analyticsStatus && options.analyticsStatus !== 200) { + if ( + options.analyticsStatus && + options.analyticsStatus !== 200 && + (!options.analyticsFailurePath || path.endsWith(options.analyticsFailurePath)) + ) { return new Response( JSON.stringify({ error: { @@ -225,11 +239,48 @@ function installFetch( ? { totals: { events: 1100, pageviews: 900, visitors: 350, billable_events: 1000 } } : null, } - : { - meta: responseMetadata, - series: [{ bucket: meta.effective_range.from, events: 30, pageviews: 25, visitors: 20 }], - comparison: null, - }; + : path.endsWith("/timeseries") + ? { + meta: responseMetadata, + series: [ + { bucket: meta.effective_range.from, events: 30, pageviews: 25, visitors: 20 }, + ], + comparison: null, + } + : path.endsWith("/analytics/pages") + ? { + meta: responseMetadata, + items: options.emptyReports + ? [] + : [ + { + page_path: "/blog/openanalytics", + views: 712, + visitors: 518, + entrances: 80, + exits: 42, + bounces: 20, + bounce_rate: 0.25, + ignored: privateKey, + }, + ], + } + : { + meta: responseMetadata, + items: options.emptyReports + ? [] + : (options.sourceRows ?? [ + { + referrer_domain: "reddit.com", + utm_source: "reddit", + utm_medium: "social", + utm_campaign: "launch", + views: 301, + visitors: 230, + ignored: privateKey, + }, + ]), + }; return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" }, @@ -485,7 +536,12 @@ describe("OpenAnalytics native admin page", () => { expect(output).toContain("Previous period: 350"); expect(output).toContain("Previous period: 900"); expect(output).not.toContain(privateKey); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenCalledTimes(5); + expect(output).toContain("Top Pages"); + expect(output).toContain("/blog/openanalytics"); + expect(output).toContain("Traffic Sources"); + expect(output).toContain("reddit.com"); + expect(output).toContain("medium: social"); for (const { url, init } of requests) { expect(url).not.toContain(privateKey); if (url.includes("analytics/")) { @@ -494,7 +550,11 @@ describe("OpenAnalytics native admin page", () => { expect(parsed.searchParams.get("to")).toBeTruthy(); expect(parsed.searchParams.get("timezone")).toBe("America/New_York"); expect(parsed.searchParams.get("resolution")).toBe( - parsed.pathname.endsWith("/overview") ? "hour" : "day", + parsed.pathname.endsWith("/overview") || parsed.pathname.endsWith("/timeseries") + ? parsed.pathname.endsWith("/overview") + ? "hour" + : "day" + : null, ); expect(parsed.searchParams.get("compare")).toBe( parsed.pathname.endsWith("/overview") ? "true" : null, @@ -502,6 +562,15 @@ describe("OpenAnalytics native admin page", () => { expect(init?.headers).toMatchObject({ Authorization: `Bearer ${privateKey}` }); } } + const firstRangeQueries = requests + .filter(({ url }) => url.includes("analytics/")) + .map(({ url }) => new URL(url)); + expect(firstRangeQueries).toHaveLength(4); + for (const query of firstRangeQueries) { + expect(query.searchParams.get("from")).toBe(firstRangeQueries[0]!.searchParams.get("from")); + expect(query.searchParams.get("to")).toBe(firstRangeQueries[0]!.searchParams.get("to")); + expect(query.searchParams.get("timezone")).toBe("America/New_York"); + } const hourly = await dispatchAdmin( runtime, { type: "block_action", action_id: "range", value: "24h", page: "/analytics" }, @@ -509,7 +578,7 @@ describe("OpenAnalytics native admin page", () => { ); expect(hourly.response.status).toBe(200); const analyticsRequests = requests.filter(({ url }) => url.includes("analytics/")); - const lastPair = analyticsRequests.slice(-2).map(({ url }) => new URL(url)); + const lastPair = analyticsRequests.slice(-4).map(({ url }) => new URL(url)); const recentQuery = lastPair[0]!; const from = Date.parse(recentQuery.searchParams.get("from")!); const to = Date.parse(recentQuery.searchParams.get("to")!); @@ -535,26 +604,31 @@ describe("OpenAnalytics native admin page", () => { expect(selected.response.status).toBe(200); const pair = requests .filter(({ url }) => url.includes("analytics/")) - .slice(-2) + .slice(-4) .map(({ url }) => new URL(url)); + const byPath = (suffix: string) => pair.find((item) => item.pathname.endsWith(suffix))!; const rangeMs = - Date.parse(pair[0]!.searchParams.get("to")!) - - Date.parse(pair[0]!.searchParams.get("from")!); + Date.parse(byPath("/overview").searchParams.get("to")!) - + Date.parse(byPath("/overview").searchParams.get("from")!); expect(rangeMs).toBeCloseTo(days * 24 * 60 * 60 * 1000, -2); - expect( - pair.find((item) => item.pathname.endsWith("/overview"))!.searchParams.get("resolution"), - ).toBe("hour"); - expect( - pair.find((item) => item.pathname.endsWith("/timeseries"))!.searchParams.get("resolution"), - ).toBe("day"); - expect(pair[0]!.searchParams.get("from")).toBe(pair[1]!.searchParams.get("from")); - expect(pair[0]!.searchParams.get("to")).toBe(pair[1]!.searchParams.get("to")); - expect( - pair.find((item) => item.pathname.endsWith("/overview"))!.searchParams.get("compare"), - ).toBe("true"); - expect( - pair.find((item) => item.pathname.endsWith("/timeseries"))!.searchParams.has("compare"), - ).toBe(false); + expect(byPath("/overview").searchParams.get("resolution")).toBe("hour"); + expect(byPath("/timeseries").searchParams.get("resolution")).toBe("day"); + for (const reportPath of ["/pages", "/sources"]) { + const report = byPath(reportPath); + expect(report.searchParams.get("from")).toBe(byPath("/overview").searchParams.get("from")); + expect(report.searchParams.get("to")).toBe(byPath("/overview").searchParams.get("to")); + expect(report.searchParams.get("timezone")).toBe("America/New_York"); + expect(report.searchParams.get("limit")).toBe("10"); + expect(report.searchParams.has("resolution")).toBe(false); + } + expect(byPath("/timeseries").searchParams.get("from")).toBe( + byPath("/overview").searchParams.get("from"), + ); + expect(byPath("/timeseries").searchParams.get("to")).toBe( + byPath("/overview").searchParams.get("to"), + ); + expect(byPath("/overview").searchParams.get("compare")).toBe("true"); + expect(byPath("/timeseries").searchParams.has("compare")).toBe(false); expect(JSON.stringify(selected.data)).toContain(`Last ${days} days`); } }); @@ -581,6 +655,179 @@ describe("OpenAnalytics native admin page", () => { expect(requests.some(({ url }) => url.includes("analytics/"))).toBe(true); }); + it("renders empty report states as native tables with no pagination controls", async () => { + const runtime = await makeRuntime(); + installFetch({ emptyReports: true }); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "UTC", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + const result = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + const text = JSON.stringify(result.data); + expect(text).toContain("No page activity in this range."); + expect(text).toContain("No traffic sources recorded in this range."); + const tables = (result.data.blocks?.filter((block) => block.type === "table") ?? []) as Array<{ + rows?: unknown[]; + next_cursor?: unknown; + columns?: Array<{ sortable?: boolean }>; + }>; + expect(tables).toHaveLength(2); + for (const table of tables) { + expect(table.rows).toEqual([]); + expect(table.next_cursor).toBeUndefined(); + expect(table.columns?.some((column: { sortable?: boolean }) => column.sortable)).toBe(false); + } + expect(validateBlockResponse(result.data, { pluginPagePaths: ["/analytics"] }).valid).toBe( + true, + ); + }); + + it("labels tagged traffic without misclassifying it as direct or internal", async () => { + const runtime = await makeRuntime(); + installFetch({ + sourceRows: [ + { + referrer_domain: "", + utm_source: "newsletter", + utm_medium: "email", + utm_campaign: "launch", + views: 42, + visitors: 31, + }, + { + referrer_domain: "", + utm_source: "", + utm_medium: "paid", + utm_campaign: "spring", + views: 12, + visitors: 8, + }, + { + referrer_domain: "", + utm_source: "", + utm_medium: "", + utm_campaign: "", + views: 5, + visitors: 4, + }, + ], + }); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "UTC", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + const result = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + const table = result.data.blocks?.find( + (block) => block.type === "table" && JSON.stringify(block.columns).includes('"source"'), + ); + const text = JSON.stringify(table); + expect(text).toContain("newsletter · medium: email · campaign: launch"); + expect(text).toContain("No referrer · medium: paid · campaign: spring"); + expect(text).toContain("Direct / internal"); + expect(text).not.toContain("Direct / internal · newsletter"); + }); + + it.each([ + [ + "/v1/read/analytics/pages", + "Top pages temporarily unavailable.", + "Traffic Sources", + "reddit.com", + ], + [ + "/v1/read/analytics/sources", + "Traffic sources temporarily unavailable.", + "/blog/openanalytics", + "Visitors", + ], + ] as const)( + "isolates report failure for %s", + async (failurePath, sectionMessage, visiblePage, otherReport) => { + const runtime = await makeRuntime(); + installFetch({ analyticsStatus: 503, analyticsFailurePath: failurePath }); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "UTC", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + const result = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + const output = JSON.stringify(result.data); + expect(output).toContain("Visitors"); + expect(output).toContain(visiblePage); + expect(output).toContain(otherReport); + expect(output).toContain(sectionMessage); + expect(output).not.toContain(privateKey); + }, + ); + + it.each([ + ["/overview", "Overview unavailable", "Chart covers", "reddit.com"], + ["/timeseries", "Chart unavailable", "Previous period: 350", "/blog/openanalytics"], + ] as const)( + "keeps the other analytics sections visible when %s fails", + async (failurePath, message, survivingOverview, survivingReport) => { + const runtime = await makeRuntime(); + installFetch({ analyticsStatus: 503, analyticsFailurePath: failurePath }); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "UTC", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + const result = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + const output = JSON.stringify(result.data); + expect(output).toContain(message); + expect(output).toContain(survivingOverview); + expect(output).toContain(survivingReport); + expect(output).not.toContain(privateKey); + }, + ); + + it("shows tracker installation separately from suspended collection state", async () => { + const runtime = await makeRuntime(); + installFetch({ site: { ...site, status: "suspended" } }); + await setSettings(runtime, { + apiUrl, + privateReadKey: privateKey, + trackingEnabled: true, + timezone: "UTC", + }); + await validate(runtime, { role: 50, tokenScopes: ["admin"] }); + const result = await dispatchAdmin( + runtime, + { type: "page_load", page: "/analytics" }, + { role: 50, tokenScopes: ["admin"] }, + ); + expect(JSON.stringify(result.data)).toContain("Tracker installed · collection suspended"); + expect(JSON.stringify(result.data)).not.toContain("Tracking inactive"); + expect(await renderedTracking(runtime)).toContain('data-key="oa_pk_admin_public"'); + }); + it.each([ [403, "This private key does not have analytics:read permission."], [402, "OpenAnalytics paused this request because of a billing or service issue."], @@ -722,7 +969,7 @@ describe("OpenAnalytics native admin page", () => { expect(validated.response.status).toBe(200); expect(JSON.stringify(validated.data)).toContain("Connected"); expect(JSON.stringify(validated.data)).not.toContain(privateKey); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenCalledTimes(5); }); it("keeps a matching validated snapshot through transient revalidation failures", async () => { diff --git a/tests/analytics-client.test.ts b/tests/analytics-client.test.ts index 3235ea7..2020a48 100644 --- a/tests/analytics-client.test.ts +++ b/tests/analytics-client.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getOverview, getTimeseries } from "../src/openanalytics/client"; +import { getOverview, getPages, getSources, getTimeseries } from "../src/openanalytics/client"; import { OpenAnalyticsError } from "../src/openanalytics/errors"; import { parseConfiguration } from "../src/settings/config"; @@ -51,6 +51,41 @@ const timeseries = { comparison: null, }; +const reportQuery = { + from: query.from, + to: query.to, + timezone: query.timezone, +}; + +const pages = { + meta, + items: [ + { + page_path: "/blog/openanalytics", + views: 712, + visitors: 518, + entrances: 201, + exits: 95, + bounces: 41, + bounce_rate: 41 / 201, + }, + ], +}; + +const sources = { + meta, + items: [ + { + referrer_domain: "google.com", + utm_source: "", + utm_medium: "", + utm_campaign: "", + views: 240, + visitors: 210, + }, + ], +}; + function mockFetch(status: number, body: unknown, headers: HeadersInit = {}) { const response = new Response(typeof body === "string" ? body : JSON.stringify(body), { status, @@ -64,6 +99,157 @@ function mockFetch(status: number, body: unknown, headers: HeadersInit = {}) { afterEach(() => vi.unstubAllGlobals()); describe("OpenAnalytics analytics read client", () => { + it.each([ + ["pages", getPages, pages], + ["sources", getSources, sources], + ] as const)( + "reads typed %s rows with the explicit selected range, timezone, and limit", + async (name, call, body) => { + const fetchMock = mockFetch(200, body); + const result = await call(config, reportQuery); + expect(result).toEqual(body); + const [input, init] = fetchMock.mock.calls[0] ?? []; + const url = new URL(String(input)); + expect(url.pathname).toBe(`/v1/read/analytics/${name}`); + expect(url.searchParams.get("from")).toBe(query.from); + expect(url.searchParams.get("to")).toBe(query.to); + expect(url.searchParams.get("timezone")).toBe(query.timezone); + expect(url.searchParams.get("limit")).toBe("10"); + expect(url.searchParams.get("sort")).toBe(name === "pages" ? "views" : null); + expect(url.searchParams.has("resolution")).toBe(false); + expect(init).toMatchObject({ redirect: "error", cache: "no-store" }); + }, + ); + + it.each([ + ["pages", getPages, pages], + ["sources", getSources, sources], + ] as const)( + "accepts an empty %s result and supports a custom top-N limit", + async (_name, call, body) => { + const fetchMock = mockFetch(200, { ...body, items: [] }); + expect((await call(config, { ...reportQuery, limit: 500 })).items).toEqual([]); + expect(new URL(String(fetchMock.mock.calls[0]?.[0])).searchParams.get("limit")).toBe("500"); + }, + ); + + it.each([ + ["pages", getPages, pages], + ["sources", getSources, sources], + ] as const)( + "projects documented %s fields and ignores additive upstream fields", + async (_name, call, body) => { + mockFetch(200, { + meta: { ...meta, ignored_future_meta: readKey }, + items: [{ ...body.items[0], ignored_future_field: readKey }], + ignored: readKey, + }); + const result = await call(config, reportQuery); + expect(JSON.stringify(result)).not.toContain(readKey); + expect(result.items[0]).not.toHaveProperty("ignored_future_field"); + expect(result.meta).not.toHaveProperty("ignored_future_meta"); + }, + ); + + it.each([ + ["pages", getPages, pages, "items"], + ["sources", getSources, sources, "items"], + ] as const)("rejects malformed %s rows and metadata", async (_name, call, body, field) => { + mockFetch(200, { ...body, [field]: [{ ...body.items[0], views: -1 }] }); + await expect(call(config, reportQuery)).rejects.toMatchObject({ kind: "invalid_response" }); + mockFetch(200, { ...body, meta: { ...meta, truncated: "false" } }); + await expect(call(config, reportQuery)).rejects.toMatchObject({ kind: "invalid_response" }); + mockFetch(200, { ...body, meta: { ...meta, freshness: null } }); + await expect(call(config, reportQuery)).rejects.toMatchObject({ kind: "invalid_response" }); + mockFetch(200, { + ...body, + meta: { ...meta, comparison_range: { from: query.from, to: query.to } }, + }); + await expect(call(config, reportQuery)).rejects.toMatchObject({ kind: "invalid_response" }); + }); + + it.each([ + { ...pages, items: [{ ...pages.items[0], entrances: undefined }] }, + { ...pages, items: [{ ...pages.items[0], bounce_rate: 1.1 }] }, + { + ...pages, + items: [ + { + ...pages.items[0], + entrances: 0, + exits: 0, + bounces: 0, + bounce_rate: null, + }, + ], + }, + { + ...pages, + items: [ + { + ...pages.items[0], + entrances: null, + exits: null, + bounces: null, + bounce_rate: null, + }, + ], + }, + ])("rejects missing or invalid nullable session measures", async (body) => { + mockFetch(200, body); + if (body.items[0]?.entrances === 0 || body.items[0]?.entrances === null) { + expect((await getPages(config, reportQuery)).items[0]).toMatchObject(body.items[0]); + } else { + await expect(getPages(config, reportQuery)).rejects.toMatchObject({ + kind: "invalid_response", + }); + } + }); + + it("rejects private-key material in projected page or source fields", async () => { + mockFetch(200, { ...pages, items: [{ ...pages.items[0], page_path: `/path/${readKey}` }] }); + await expect(getPages(config, reportQuery)).rejects.toMatchObject({ kind: "invalid_response" }); + mockFetch(200, { + ...sources, + items: [{ ...sources.items[0], utm_campaign: encodeURIComponent(readKey) }], + }); + await expect(getSources(config, reportQuery)).rejects.toMatchObject({ + kind: "invalid_response", + }); + }); + + it.each([ + [401, {}, "unauthorized"], + [403, { error: { code: "FORBIDDEN" } }, "analytics_forbidden"], + [403, { error: { code: "SITE_SUSPENDED" } }, "suspended"], + [400, { error: { code: "RANGE_TOO_LARGE" } }, "range_invalid"], + [429, {}, "rate_limited"], + [500, {}, "server"], + [503, {}, "server"], + ] as const)( + "normalizes report HTTP %i without exposing response content", + async (status, payload, kind) => { + mockFetch(status, { ...payload, detail: readKey }, { "retry-after": "13" }); + await expect(getPages(config, reportQuery)).rejects.toMatchObject({ kind, status }); + mockFetch(status, { ...payload, detail: readKey }, { "retry-after": "13" }); + await expect(getSources(config, reportQuery)).rejects.toMatchObject({ kind, status }); + }, + ); + + it.each([getPages, getSources] as const)( + "rejects invalid report limits before requesting", + async (call) => { + for (const limit of [0, 501, 1.5]) { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + await expect(call(config, { ...reportQuery, limit })).rejects.toMatchObject({ + kind: "configuration", + }); + expect(fetchMock).not.toHaveBeenCalled(); + } + }, + ); + it("reads the typed overview with explicit range, timezone, and resolution", async () => { const fetchMock = mockFetch(200, overview); diff --git a/tests/http.test.ts b/tests/http.test.ts index 56eacef..d0269c5 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -3,7 +3,13 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { getOverview, getSite, getTimeseries } from "../src/openanalytics/client"; +import { + getOverview, + getPages, + getSite, + getSources, + getTimeseries, +} from "../src/openanalytics/client"; import { OpenAnalyticsError } from "../src/openanalytics/errors"; import { parseConfiguration } from "../src/settings/config"; @@ -56,6 +62,8 @@ async function route(request: IncomingMessage, response: ServerResponse) { "/api-root/v1/read/site", "/api-root/v1/read/analytics/overview", "/api-root/v1/read/analytics/timeseries", + "/api-root/v1/read/analytics/pages", + "/api-root/v1/read/analytics/sources", ].includes(path) ) { response.writeHead(404); @@ -86,7 +94,7 @@ async function route(request: IncomingMessage, response: ServerResponse) { requested_range: { from: url.searchParams.get("from"), to: url.searchParams.get("to") }, effective_range: { from: url.searchParams.get("from"), to: url.searchParams.get("to") }, timezone: url.searchParams.get("timezone"), - resolution: url.searchParams.get("resolution"), + resolution: url.searchParams.get("resolution") ?? "hour", data_sources: ["live"], accuracy: "exact", freshness: { state: "ok", watermark: analyticsQuery.to, as_of: analyticsQuery.to }, @@ -101,11 +109,40 @@ async function route(request: IncomingMessage, response: ServerResponse) { totals: { events: 1200, pageviews: 980, visitors: 380, billable_events: 1150 }, comparison: null, } - : { - meta, - series: [{ bucket: analyticsQuery.from, events: 50, pageviews: 40, visitors: 20 }], - comparison: null, - }; + : path.endsWith("/timeseries") + ? { + meta, + series: [{ bucket: analyticsQuery.from, events: 50, pageviews: 40, visitors: 20 }], + comparison: null, + } + : path.endsWith("/pages") + ? { + meta, + items: [ + { + page_path: "/", + views: 40, + visitors: 20, + entrances: 12, + exits: 8, + bounces: 3, + bounce_rate: 0.25, + }, + ], + } + : { + meta, + items: [ + { + referrer_domain: "google.com", + utm_source: "", + utm_medium: "", + utm_campaign: "", + views: 40, + visitors: 20, + }, + ], + }; sendJson(response, body); } @@ -204,4 +241,34 @@ describe("OpenAnalytics HTTP transport", () => { slowResponse = false; } }); + + it.each([ + ["pages", getPages], + ["sources", getSources], + ] as const)("refuses redirects for the %s report without following them", async (_name, call) => { + redirectedRequestCount = 0; + redirectToFixture = true; + try { + await expect( + call(parseConfiguration({ apiUrl: baseUrl, readKey }), analyticsQuery), + ).rejects.toMatchObject({ kind: "network" }); + expect(redirectedRequestCount).toBe(0); + } finally { + redirectToFixture = false; + } + }); + + it.each([ + ["pages", getPages], + ["sources", getSources], + ] as const)("times out while the %s report response body is pending", async (_name, call) => { + slowResponse = true; + try { + await expect( + call(parseConfiguration({ apiUrl: baseUrl, readKey, timeoutMs: 20 }), analyticsQuery), + ).rejects.toMatchObject({ kind: "timeout" }); + } finally { + slowResponse = false; + } + }); });