diff --git a/CHANGELOG.md b/CHANGELOG.md index b2bd527..c4428cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,36 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve app can add exact account counts and sign-up retention when the two are linked. - Clicks on the landing page's Sign up free and Web app links are counted. +- The funnel gains "Copied an install command" from the landing page's copy + buttons, and lists sessions created but never connected beside "Started a + session". A named `utm_source` or `ref` on a landing link counts as the + source when the browser hid the referrer, from a fixed list of names; + visits from the web app are their own source. +- A session's first open records whether typing is allowed and how long the + link waited; the first keystroke, how long after the open it came; a + viewer's disconnect, how long they stayed. Browsers turned away by a full, + expired or unknown session are counted by reason, and a viewer refused + input in a read-only session once. The dashboard shows typed rate by + device, who was turned away, and the typed share over sessions that allow + typing. +- Machines running the installer or the CLI are keyed by address alone, so + the dashboard can say how many machines that installed at least a week ago + started a session within seven days. The privacy policy says so. +- Both install scripts send one word at their end, the outcome, and the + binary name, so a platform that keeps failing gets noticed; nothing else + goes with it, and `SHELL_ONLINE_INSTALL_REPORT=0` skips it. The dashboard + shows how installs ended by the scripts' own account. +- The accounts app counts what accounts do, by day and by kind and nothing + else: machines linked, sessions registered, commands sent, vaults created, + invites sent and accepted, feedback sent. The dashboard shows them under + Accounts as things done, not as distinct accounts. +- The statistics dashboard reads top to bottom as a story: what is live now, + six headline figures each with its change against the period before, the + funnel, then traffic, sessions, retention and accounts, each section opening + with the finding in a sentence. Every figure about people leaves crawlers + out and says so beside the step: page views by people, the installer run by + curl or wget rather than read or crawled, installs completed on a person's + machine. The raw totals stay in the ledger. ### Fixed @@ -47,6 +77,17 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve as "Not found". Every documentation route, current or versioned, now has its own page-view target; unknown paths the site answers with the landing page are counted apart from real 404s, and real 404s are counted at all. +- The statistics dashboard says since when people have been counted. Event + counts run from the first event and people from the day the visitor salt was + set, so a 30-day range could show thirty days of views beside one day of + people. A people figure over fewer days than the count beside it now names + that day, in the funnel, the headline tiles and the footer. +- Crawlers that identify themselves are no longer counted as people. The + funnel said they were not, and they were. +- Deploying to production refuses a Wrangler config that serves a documentation + page from the assets binding instead of the Worker, since such a page is + never counted. Production served the web app, CLI, Refstream and platforms + pages that way. ### Changed diff --git a/README.md b/README.md index 8bf926a..4d37b83 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ Windows PowerShell: irm https://shell.online/install.ps1 | iex ``` +At the end, and on failure, the installer sends one word back, its outcome, +so a platform that keeps failing gets noticed and fixed. Nothing else goes +with it. Set `SHELL_ONLINE_INSTALL_REPORT=0` to skip that. + Homebrew (the tap lives in this repository): ```sh diff --git a/app/server/app.test.ts b/app/server/app.test.ts index 8d9ad9b..f4e76b8 100644 --- a/app/server/app.test.ts +++ b/app/server/app.test.ts @@ -236,6 +236,8 @@ describe("session registry", () => { const tokens = await login(); const created = await call("POST", "/api/sessions", { auth: tokens.access_token, body: session }); expect(created.status).toBe(201); + /* Counted for the dashboard: the login linked a machine, the registration a session. */ + expect(await store.appEvents(0)).toEqual([{ event: "machine_linked", count: 1 }, { event: "session_registered", count: 1 }]); const listed = await call("GET", "/api/sessions", { auth: await idToken() }); expect(listed.status).toBe(200); @@ -556,6 +558,7 @@ describe("driving a machine from the browser", () => { body: { device_id: deviceId, kind: "start", command: "top" }, }); expect(queued.status).toBe(202); + expect((await store.appEvents(0)).find((entry) => entry.event === "command_sent")?.count).toBe(1); const claimed = await call("GET", "/api/agent/commands", { auth: tokens.access_token }); expect(claimed.body.commands).toHaveLength(1); @@ -1041,6 +1044,8 @@ describe("organizations", () => { expect(joined.body.joined).toBe(true); expect(joined.body.you.role).toBe("member"); expect(joined.body.members).toHaveLength(2); + /* Both halves counted for the dashboard, with nothing about who. */ + expect(await store.appEvents(0)).toEqual([{ event: "invite_accepted", count: 1 }, { event: "invite_created", count: 1 }]); }); it("shows colleagues each other's sessions", async () => { @@ -2031,6 +2036,7 @@ describe("session vault", () => { const { body } = await vaultBody(); const created = await call("POST", "/api/vault", { auth: await idToken(), body }); expect(created.status).toBe(201); + expect((await store.appEvents(0)).find((entry) => entry.event === "vault_created")?.count).toBe(1); const fetched = await call("GET", "/api/vault", { auth: await idToken() }); expect(fetched.body.vault).toMatchObject({ publicKey: body.public_key, @@ -2542,6 +2548,12 @@ describe("feedback", () => { const posted = await call("POST", "/api/feedback", { auth: await idToken(), body: message }); expect(posted.status).toBe(201); expect(posted.body.feedback.id).toMatch(/^fbk_/); + /* Counted for the dashboard as a thing done, with nothing about who did it. */ + expect(await store.appEvents(0)).toEqual([{ event: "feedback_sent", count: 1 }]); + /* Counting must never change an answer: a store that cannot count still answers 201. */ + vi.spyOn(store, "recordAppEvent").mockRejectedValueOnce(new Error("db down")); + const again = await call("POST", "/api/feedback", { auth: await idToken(), body: message }); + expect(again.status).toBe(201); const [kept] = await store.feedback(); expect(kept).toMatchObject({ uid: "uid-1", @@ -2599,9 +2611,18 @@ describe("account figures for the statistics dashboard", () => { expect((await call("GET", "/api/stats/accounts?range=7d", { auth: "stats-token-with-thirty-two-characters?" })).status).toBe(401); expect((await call("GET", "/api/stats/accounts?range=7d", { auth: await idToken() })).status).toBe(401); + await store.recordAppEvent("machine_linked"); + await store.recordAppEvent("machine_linked"); + await store.recordAppEvent("command_sent"); const answer = await call("GET", "/api/stats/accounts?range=7d", { auth: TOKEN }); expect(answer.status).toBe(200); - expect(answer.body).toMatchObject({ total: 1, newInRange: 1, activeInRange: 1 }); + expect(answer.body).toMatchObject({ total: 1, newInRange: 1, activeInRange: 1, events: { machine_linked: 2, command_sent: 1 } }); + /* Only the range's days count; the all-time range counts every day kept. */ + await store.recordAppEvent("vault_created", Date.now() - 40 * 24 * 60 * 60_000); + const week = await call("GET", "/api/stats/accounts?range=7d", { auth: TOKEN }); + expect(week.body.events).toEqual({ machine_linked: 2, command_sent: 1 }); + const all = await call("GET", "/api/stats/accounts?range=all", { auth: TOKEN }); + expect(all.body.events).toEqual({ machine_linked: 2, command_sent: 1, vault_created: 1 }); expect(answer.body.newByDay).toHaveLength(90); expect(answer.body.cohorts).toHaveLength(1); expect(answer.body.cohorts[0].size).toBe(1); diff --git a/app/server/app.ts b/app/server/app.ts index e3ec77f..c0e388b 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -1,7 +1,9 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { Store } from "./lib/store"; import type { Invite, Membership } from "./lib/orgs"; -import type { AuditEvent, SessionRecord } from "./lib/types"; +import type { AuditEvent, SessionRecord, + AppEvent, +} from "./lib/types"; import type { VerifyResult } from "./lib/firebase-token"; import type { SessionLiveness, SessionLivenessSource } from "./lib/session-liveness"; import { exchangeCode, issueCode } from "./lib/codes"; @@ -45,7 +47,7 @@ import { recordAudit, assignSession, auditCsv, SEALED_KINDS } from "./routes/aud import { addComment, inbox, notifyAssigned, notifySessionStarted } from "./routes/social"; import { deleteAccount } from "./routes/account"; import { submitFeedback } from "./routes/feedback"; -import { accountStats, isStatsRange } from "./routes/stats"; +import { accountStats, dayStart, isStatsRange, rangeStart } from "./routes/stats"; import { timingSafeEqual } from "node:crypto"; import { callerAddress, rateLimiter } from "./lib/rate-limit"; import { logMailer, type Mailer } from "./lib/mail"; @@ -378,6 +380,15 @@ export function createApp(options: AppOptions) { return membership; } + /* + * One count per thing done, never who did it, for the statistics + * dashboard. Counting must never change an answer, so a store that cannot + * count is nobody's problem here. + */ + const track = (event: AppEvent): void => { + void store.recordAppEvent(event).catch(() => undefined); + }; + /* The CLI authenticates with an opaque access token issued by this service. */ async function requireCli(request: IncomingMessage) { const check = await checkAccessToken(store, bearer(request)); @@ -483,6 +494,7 @@ export function createApp(options: AppOptions) { label: String(body.label ?? "shell cli").slice(0, 80), machineId, }); + track("machine_linked"); return send(response, 200, { access_token: tokens.accessToken, refresh_token: tokens.refreshToken, @@ -536,6 +548,7 @@ export function createApp(options: AppOptions) { return send(response, 400, { error: "invalid browser public key" }); } if (publicKey) await store.setMemberKey(identity.uid, publicKey); + if (resolved.joined && invite) track("invite_accepted"); const described = await describeOrganization(store, resolved.membership); return send(response, described.status, { ...(described.body as Record), @@ -576,6 +589,7 @@ export function createApp(options: AppOptions) { const invite = (result.body as { invite?: Invite }).invite; if (invite) await notifyInvited(store, mailer, webOrigin, membership, invite, log); } + if (result.status < 300) track("invite_created"); return send(response, result.status, result.body); } @@ -916,6 +930,7 @@ export function createApp(options: AppOptions) { }); if (!created) return send(response, 409, { error: "this account already has a vault" }); const stored = await store.accountKey(identity.uid); + track("vault_created"); return send(response, 201, { vault: stored ? vaultForApi(stored) : null }); } @@ -1068,6 +1083,7 @@ export function createApp(options: AppOptions) { result.session.name || result.session.command, ); } + track("session_registered"); return send(response, 201, { session: sessionForApi(result.session) }); } @@ -1282,6 +1298,7 @@ export function createApp(options: AppOptions) { createdAt: Date.now(), }; await store.putCommand(queued); + track("command_sent"); return send(response, 202, { command: queued }); } @@ -1468,6 +1485,7 @@ export function createApp(options: AppOptions) { log, ); if (!result.ok) return send(response, result.status, { error: result.error }); + track("feedback_sent"); return send(response, 201, { feedback: { id: result.value.id, at: result.value.at } }); } @@ -1480,8 +1498,15 @@ export function createApp(options: AppOptions) { const matches = presented.length === expected.length && timingSafeEqual(Buffer.from(presented), Buffer.from(expected)); if (!matches) return send(response, 401, { error: "sign in first" }); - const range = url.searchParams.get("range"); - return send(response, 200, accountStats(await store.accountActivity(), isStatsRange(range) ? range : "7d")); + const requested = url.searchParams.get("range"); + const range = isStatsRange(requested) ? requested : "7d"; + const now = Date.now(); + return send(response, 200, accountStats( + await store.accountActivity(), + range, + now, + await store.appEvents(dayStart(rangeStart(range, now))), + )); } /* ---- Inbox ---- */ diff --git a/app/server/lib/migrations/013_app_events.sql b/app/server/lib/migrations/013_app_events.sql new file mode 100644 index 0000000..1ac6cc9 --- /dev/null +++ b/app/server/lib/migrations/013_app_events.sql @@ -0,0 +1,13 @@ +-- What accounts did in the app, counted by day and by kind and nothing else: +-- no account, no address, no session. The statistics dashboard reads totals +-- over a range from it, so it can say how many machines were linked or +-- commands sent next to how many accounts there are. Rows are dropped after +-- the same 400 days as activity days. +CREATE TABLE IF NOT EXISTS app_events ( + event TEXT NOT NULL, + day BIGINT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (event, day) +); + +CREATE INDEX IF NOT EXISTS app_events_day ON app_events (day); diff --git a/app/server/lib/store-conformance.test.ts b/app/server/lib/store-conformance.test.ts index 069f22c..2c924c4 100644 --- a/app/server/lib/store-conformance.test.ts +++ b/app/server/lib/store-conformance.test.ts @@ -149,6 +149,7 @@ function feedback(overrides: Partial = {}): Feedback { const TABLES = [ "feedback", "account_activity", + "app_events", "deleted_accounts", "account_keys", "session_key_shares", @@ -943,6 +944,29 @@ for (const implementation of implementations) { }); }); + describe("app events", () => { + const day = 24 * 60 * 60_000; + const noon = 10 * day + 12 * 60 * 60_000; + + it("counts by kind and day, sums from a day on, and says nothing about who", async () => { + await store.recordAppEvent("machine_linked", noon); + await store.recordAppEvent("machine_linked", noon + 60_000); + await store.recordAppEvent("command_sent", noon + day); + expect(await store.appEvents(0)).toEqual([ + { event: "command_sent", count: 1 }, + { event: "machine_linked", count: 2 }, + ]); + expect(await store.appEvents(11 * day)).toEqual([{ event: "command_sent", count: 1 }]); + expect(await store.appEvents(12 * day)).toEqual([]); + }); + + it("forgets counts older than the memory window when purging", async () => { + await store.recordAppEvent("vault_created", noon); + await store.purgeExpired(noon + 401 * day); + expect(await store.appEvents(0)).toEqual([]); + }); + }); + describe("claiming an organization on first sight", () => { /* * Signing in fires several requests at once. On a new account none of diff --git a/app/server/lib/store-memory.ts b/app/server/lib/store-memory.ts index ed11874..98fe30a 100644 --- a/app/server/lib/store-memory.ts +++ b/app/server/lib/store-memory.ts @@ -14,6 +14,8 @@ import { } from "./store"; import type { AccountActivity, + AppEvent, + AppEventCount, AccountKey, AgentCommand, AuditEvent, @@ -73,6 +75,7 @@ interface Shape { accountKeys: AccountKey[]; deletedAccounts: { uid: string; deletedAt: number }[]; accountActivity: { uid: string; day: number }[]; + appEvents: { event: AppEvent; day: number; count: number }[]; teamKeys: TeamKey[]; teamKeyShares: TeamKeyShare[]; } @@ -81,7 +84,7 @@ const EMPTY: Shape = { codes: [], tokens: [], sessions: [], commands: [], organizations: [], memberships: [], invites: [], audit: [], comments: [], notifications: [], feedback: [], accountKeys: [], deletedAccounts: [], - accountActivity: [], teamKeys: [], teamKeyShares: [], + accountActivity: [], appEvents: [], teamKeys: [], teamKeyShares: [], }; /** @@ -148,6 +151,7 @@ export class MemoryStore implements Store { accountKeys: parsed.accountKeys ?? [], deletedAccounts: parsed.deletedAccounts ?? [], accountActivity: parsed.accountActivity ?? [], + appEvents: parsed.appEvents ?? [], teamKeys: parsed.teamKeys ?? [], teamKeyShares: parsed.teamKeyShares ?? [], }; @@ -823,6 +827,9 @@ export class MemoryStore implements Store { this.data.accountActivity = this.data.accountActivity.filter( (entry) => entry.day >= now - ACCOUNT_ACTIVITY_MEMORY_MS, ); + this.data.appEvents = this.data.appEvents.filter( + (entry) => entry.day >= now - ACCOUNT_ACTIVITY_MEMORY_MS, + ); const before = this.data.codes.length; this.data.codes = this.data.codes.filter((entry) => entry.expiresAt > now); /* Finished commands are only kept long enough to be reported back. */ @@ -893,6 +900,26 @@ export class MemoryStore implements Store { })); } + /* ---- App events ---- */ + + async recordAppEvent(event: AppEvent, now = Date.now()): Promise { + const day = Math.floor(now / DAY_MS) * DAY_MS; + const row = this.data.appEvents.find((entry) => entry.event === event && entry.day === day); + if (row) row.count += 1; + else this.data.appEvents.push({ event, day, count: 1 }); + this.flush(); + } + + async appEvents(sinceDay: number): Promise { + const totals = new Map(); + for (const entry of this.data.appEvents) { + if (entry.day >= sinceDay) totals.set(entry.event, (totals.get(entry.event) ?? 0) + entry.count); + } + return [...totals.entries()] + .map(([event, count]) => ({ event, count })) + .sort((left, right) => left.event.localeCompare(right.event)); + } + async tokensForImport(): Promise { return this.data.tokens; } diff --git a/app/server/lib/store-postgres.ts b/app/server/lib/store-postgres.ts index 4331c47..0d2fa99 100644 --- a/app/server/lib/store-postgres.ts +++ b/app/server/lib/store-postgres.ts @@ -16,6 +16,8 @@ import { } from "./store"; import type { AccountActivity, + AppEvent, + AppEventCount, AccountKey, AgentCommand, AuditEvent, @@ -1615,10 +1617,29 @@ export class PostgresStore implements Store { })); } + /* ---- App events ---- */ + + async recordAppEvent(event: AppEvent, now = Date.now()): Promise { + await this.pool.query( + `INSERT INTO app_events (event, day, count) VALUES ($1, $2, 1) + ON CONFLICT (event, day) DO UPDATE SET count = app_events.count + 1`, + [event, Math.floor(now / DAY_MS) * DAY_MS], + ); + } + + async appEvents(sinceDay: number): Promise { + const rows = await this.rows( + "SELECT event, SUM(count) AS count FROM app_events WHERE day >= $1 GROUP BY event ORDER BY event", + [sinceDay], + ); + return rows.map((row) => ({ event: row.event as AppEvent, count: Number(row.count) })); + } + /* ---- Housekeeping ---- */ async purgeExpired(now = Date.now()): Promise { await this.pool.query("DELETE FROM account_activity WHERE day < $1", [now - ACCOUNT_ACTIVITY_MEMORY_MS]); + await this.pool.query("DELETE FROM app_events WHERE day < $1", [now - ACCOUNT_ACTIVITY_MEMORY_MS]); await this.pool.query("DELETE FROM auth_codes WHERE expires_at <= $1", [now]); /* Finished commands are only kept long enough to be reported back. */ await this.pool.query("DELETE FROM agent_commands WHERE done_at IS NOT NULL AND done_at < $1", [ diff --git a/app/server/lib/store.ts b/app/server/lib/store.ts index 36c30ab..97e1d49 100644 --- a/app/server/lib/store.ts +++ b/app/server/lib/store.ts @@ -1,6 +1,8 @@ import type { Invite, Membership, Organization, Role } from "./orgs"; import type { AccountActivity, + AppEvent, + AppEventCount, AccountKey, AgentCommand, AuditEvent, @@ -258,6 +260,12 @@ export interface Store { /** Every account's sign-up time and active days, with no identifiers. */ accountActivity(): Promise; + /* ---- App events ---- */ + /** Counts one thing an account did, on the day it did it. Nothing about who. */ + recordAppEvent(event: AppEvent, now?: number): Promise; + /** Totals per kind over days on or after `sinceDay` (midnight UTC), for the dashboard. */ + appEvents(sinceDay: number): Promise; + /* ---- Housekeeping ---- */ purgeExpired(now?: number): Promise; close(): Promise; diff --git a/app/server/lib/types.ts b/app/server/lib/types.ts index 24c43fb..248d990 100644 --- a/app/server/lib/types.ts +++ b/app/server/lib/types.ts @@ -206,6 +206,27 @@ export interface AccountActivity { days: number[]; } +/** + * Things an account can do in the app that the statistics dashboard wants + * counted, without ever saying which account: linked a machine, registered + * a session, sent a command. Counted by day and by kind, nothing else. + */ +export const APP_EVENTS = [ + "machine_linked", + "session_registered", + "command_sent", + "vault_created", + "invite_created", + "invite_accepted", + "feedback_sent", +] as const; +export type AppEvent = (typeof APP_EVENTS)[number]; + +export interface AppEventCount { + event: AppEvent; + count: number; +} + export interface SessionRecord { id: string; uid: string; diff --git a/app/server/routes/stats.test.ts b/app/server/routes/stats.test.ts index f548833..050fdfe 100644 --- a/app/server/routes/stats.test.ts +++ b/app/server/routes/stats.test.ts @@ -5,6 +5,14 @@ import { accountStats, buildRetentionCohorts, DAY_MS, WEEK_MS, weekStart } from const monday = Date.UTC(2026, 8, 7); const now = monday + 2 * WEEK_MS + 3 * DAY_MS + 9 * 60 * 60_000; +describe("accountStats events", () => { + it("passes what accounts did through as counts by kind, and an empty object when nothing was counted", () => { + const stats = accountStats([], "7d", now, [{ event: "machine_linked", count: 2 }, { event: "command_sent", count: 5 }]); + expect(stats.events).toEqual({ machine_linked: 2, command_sent: 5 }); + expect(accountStats([], "7d", now).events).toEqual({}); + }); +}); + describe("accountStats", () => { const accounts = [ /* signed up week 0, used it in weeks 1 and 2 */ diff --git a/app/server/routes/stats.ts b/app/server/routes/stats.ts index 6b3ad4a..9528143 100644 --- a/app/server/routes/stats.ts +++ b/app/server/routes/stats.ts @@ -1,4 +1,5 @@ import type { AccountActivity } from "../lib/store"; +import type { AppEventCount } from "../lib/types"; /* * Account figures for the statistics dashboard on stats.shell.online. @@ -35,6 +36,8 @@ export interface AccountStats { activeInRange: number; newByDay: { day: number; count: number }[]; cohorts: RetentionCohort[]; + /** Things done in the range, by kind: machines linked, commands sent. Counts of things, not of accounts. */ + events: Record; } export function isStatsRange(value: unknown): value is StatsRange { @@ -59,7 +62,12 @@ export function rangeStart(range: StatsRange, now: number): number { return 0; } -export function accountStats(activity: AccountActivity[], range: StatsRange, now = Date.now()): AccountStats { +export function accountStats( + activity: AccountActivity[], + range: StatsRange, + now = Date.now(), + events: AppEventCount[] = [], +): AccountStats { const start = rangeStart(range, now); const startDay = dayStart(start); const newByDay = new Map(); @@ -86,6 +94,7 @@ export function accountStats(activity: AccountActivity[], range: StatsRange, now activeInRange, newByDay: [...newByDay.entries()].map(([day, count]) => ({ day, count })), cohorts: buildRetentionCohorts(rows, now), + events: Object.fromEntries(events.map((entry) => [entry.event, entry.count])), }; } diff --git a/app/src/routes/Privacy.tsx b/app/src/routes/Privacy.tsx index 02e91e2..673cd11 100644 --- a/app/src/routes/Privacy.tsx +++ b/app/src/routes/Privacy.tsx @@ -340,7 +340,9 @@ export function Privacy() { page views, installer downloads and sessions opened, with a device class, a client name and the referring site. To tell one visitor from another they keep, for 120 days, a keyed hash of the network - address and browser family. The key never leaves the server, the + address and browser family; for the installer and the command-line + tool, a keyed hash of the address alone, so an install can be + followed to a first session. The key never leaves the server, the address itself is not stored, and nothing in that record says who a visitor is. They record no session identifiers, URLs, commands, terminal content or full user-agent strings. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index a5bb176..853a223 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -68,7 +68,9 @@ Set `SHELL_ONLINE_SERVER` to the URL Wrangler prints. Add a `routes` entry to the copied config for a custom domain. The Worker path requires Durable Objects, Rate Limiting, Analytics Engine, and static assets. Keep every documentation path in `run_worker_first`: a page served straight from the -assets binding is never counted. +assets binding is never counted. `npm run deploy:production` refuses a +production config that routes fewer of these paths through the Worker than +`wrangler.example.jsonc` does. The private statistics dashboard is optional and configured with Worker secrets (`npx wrangler secret put `): @@ -76,7 +78,7 @@ secrets (`npx wrangler secret put `): | Secret | Purpose | |---|---| | `STATS_PASSWORD` | Opens the dashboard on the stats hostname; at least 12 characters | -| `STATS_VISITOR_SALT` | Lets the dashboard count people as keyed hashes of address and browser family; at least 16 characters. Without it every unique, new, returning and retention figure stays at zero | +| `STATS_VISITOR_SALT` | Lets the dashboard count people as keyed hashes of address and browser family (address alone for machines running the installer or the CLI, so an install can be followed to a first session); at least 16 characters. Without it every unique, new, returning and retention figure stays at zero | | `APP_STATS_URL` | The accounts app's origin, for exact account counts and sign-up cohorts | | `APP_STATS_TOKEN` | The token the accounts app expects; the same value as its `STATS_TOKEN` | diff --git a/public/install b/public/install index 0522aa7..0f94ffb 100755 --- a/public/install +++ b/public/install @@ -1,11 +1,32 @@ #!/bin/sh set -eu +# When it finishes, and when it fails, this script tells shell.online how it +# went: one request carrying a single word (ok, unsupported_arch, +# checksum_mismatch, ...) and the binary name, nothing else, so that a +# platform that keeps failing gets noticed and fixed. Set +# SHELL_ONLINE_INSTALL_REPORT=0 to skip it. + fail() { printf 'shell.online: %s\n' "$1" >&2 + report "${2:-failed}" exit 1 } +report() { + [ "${SHELL_ONLINE_INSTALL_REPORT:-1}" != 0 ] || return 0 + case "${base_url:-}" in + http://*|https://*) ;; + *) return 0 ;; + esac + report_url="$base_url/install/report?outcome=$1&platform=${binary_name:-unknown}" + if command -v curl >/dev/null 2>&1; then + curl -fsS -m 5 -o /dev/null "$report_url" >/dev/null 2>&1 || true + elif command -v wget >/dev/null 2>&1; then + wget -q -T 5 -O /dev/null "$report_url" >/dev/null 2>&1 || true + fi +} + base_url=${SHELL_ONLINE_BASE_URL:-https://shell.online} system_name=$(uname -s 2>/dev/null || true) machine_name=$(uname -m 2>/dev/null || true) @@ -18,7 +39,7 @@ case "$system_name" in NetBSD) platform=netbsd ;; DragonFly) platform=dragonfly ;; SunOS) platform=solaris ;; - *) fail "unsupported operating system: ${system_name:-unknown} (supported: macOS, Linux, FreeBSD, OpenBSD, NetBSD, DragonFly BSD, and Solaris)" ;; + *) fail "unsupported operating system: ${system_name:-unknown} (supported: macOS, Linux, FreeBSD, OpenBSD, NetBSD, DragonFly BSD, and Solaris)" unsupported_os ;; esac case "$machine_name" in @@ -44,7 +65,7 @@ case "$machine_name" in riscv64) architecture=riscv64 ;; s390x) architecture=s390x ;; loongarch64|loong64) architecture=loong64 ;; - *) fail "unsupported architecture: ${machine_name:-unknown}; see https://shell.online/docs/platforms/" ;; + *) fail "unsupported architecture: ${machine_name:-unknown}; see https://shell.online/docs/platforms/" unsupported_arch ;; esac if [ -n "${SHELL_ONLINE_INSTALL_DIR:-}" ]; then @@ -56,23 +77,23 @@ elif [ -n "${XDG_BIN_HOME:-}" ]; then elif [ -n "${HOME:-}" ]; then install_dir=$HOME/.local/bin else - fail 'HOME is not set; set SHELL_ONLINE_INSTALL_DIR to an absolute writable directory' + fail 'HOME is not set; set SHELL_ONLINE_INSTALL_DIR to an absolute writable directory' no_home fi case "$install_dir" in /*) ;; - *) fail "install directory must be absolute: $install_dir" ;; + *) fail "install directory must be absolute: $install_dir" install_dir_relative ;; esac case "$install_dir" in - *:*) fail "install directory cannot contain a colon: $install_dir" ;; + *:*) fail "install directory cannot contain a colon: $install_dir" install_dir_colon ;; esac temporary_root=${TMPDIR:-/tmp} if [ ! -d "$temporary_root" ] || [ ! -w "$temporary_root" ]; then - fail "temporary directory is not writable: $temporary_root" + fail "temporary directory is not writable: $temporary_root" temp_dir_unwritable fi temporary_dir=$(mktemp -d "$temporary_root/shell-online.XXXXXX") || - fail "could not create a temporary directory under $temporary_root" + fail "could not create a temporary directory under $temporary_root" temp_dir trap 'rm -rf "$temporary_dir"' EXIT HUP INT TERM binary_name="shell-$platform-$architecture" binary_url="$base_url/downloads/$binary_name" @@ -82,14 +103,14 @@ download() { destination=$2 if command -v curl >/dev/null 2>&1; then if ! curl -fsSL "$source_url" -o "$destination"; then - fail "download failed: $source_url" + fail "download failed: $source_url" download_failed fi elif command -v wget >/dev/null 2>&1; then if ! wget -q "$source_url" -O "$destination"; then - fail "download failed: $source_url" + fail "download failed: $source_url" download_failed fi else - fail 'curl or wget is required; install either tool and run the installer again' + fail 'curl or wget is required; install either tool and run the installer again' no_downloader fi } @@ -98,18 +119,18 @@ download "$base_url/downloads/SHA256SUMS" "$temporary_dir/SHA256SUMS" manifest_first_line=$(sed -n '1p' "$temporary_dir/SHA256SUMS") case "$manifest_first_line" in '/dev/null 2>&1; then @@ -117,18 +138,18 @@ if command -v sha256sum >/dev/null 2>&1; then elif command -v shasum >/dev/null 2>&1; then actual=$(shasum -a 256 "$temporary_dir/shell" | awk '{print $1}') else - fail 'sha256sum or shasum is required to verify the downloaded binary' + fail 'sha256sum or shasum is required to verify the downloaded binary' no_sha_tool fi if [ "$actual" != "$expected" ]; then - fail "downloaded binary failed checksum verification (expected $expected, received $actual)" + fail "downloaded binary failed checksum verification (expected $expected, received $actual)" checksum_mismatch fi if ! mkdir -p "$install_dir"; then - fail "could not create install directory: $install_dir" + fail "could not create install directory: $install_dir" install_dir_create fi if [ ! -d "$install_dir" ] || [ ! -w "$install_dir" ]; then - fail "install directory is not writable: $install_dir (set SHELL_ONLINE_INSTALL_DIR to another directory)" + fail "install directory is not writable: $install_dir (set SHELL_ONLINE_INSTALL_DIR to another directory)" install_dir_unwritable fi target=$install_dir/shell @@ -143,11 +164,11 @@ fi if command -v install >/dev/null 2>&1; then if ! install -m 0755 "$temporary_dir/shell" "$target"; then - fail "could not write executable: $target" + fail "could not write executable: $target" write_failed fi else if ! cp "$temporary_dir/shell" "$target" || ! chmod 0755 "$target"; then - fail "could not write executable: $target" + fail "could not write executable: $target" write_failed fi fi @@ -207,6 +228,7 @@ if [ -n "$resolved_shell" ] && [ "$resolved_shell" != "$target" ]; then fi "$target" --version +report ok printf '\nNext:\n' printf ' shell Run it in the background and print its browser link\n' printf ' shell help See the guided start, share, and stop flow\n' diff --git a/public/install.ps1 b/public/install.ps1 index 6dec300..82e607c 100644 --- a/public/install.ps1 +++ b/public/install.ps1 @@ -6,7 +6,25 @@ param( $ErrorActionPreference = "Stop" -function Fail([string]$Message) { +# When it finishes, and when it fails, this script tells shell.online how it +# went: one request carrying a single word and the binary name, nothing else, +# so that a platform that keeps failing gets noticed and fixed. Set +# SHELL_ONLINE_INSTALL_REPORT=0 to skip it. +$script:reported = $false +function Report([string]$Outcome) { + if ($env:SHELL_ONLINE_INSTALL_REPORT -eq "0") { return } + if ($BaseUrl -notmatch '^https?://') { return } + $script:reported = $true + $platform = if ($script:artifact) { $script:artifact } else { "unknown" } + try { + Invoke-WebRequest -UseBasicParsing -TimeoutSec 5 -Uri "$BaseUrl/install/report?outcome=$Outcome&platform=$platform" | Out-Null + } catch { + # Reporting must never change how the install went. + } +} + +function Fail([string]$Message, [string]$Outcome = "failed") { + Report $Outcome Write-Error "shell.online: $Message" exit 1 } @@ -16,7 +34,7 @@ $architecture = switch ($nativeArchitecture.ToUpperInvariant()) { "AMD64" { "amd64" } "X86" { "386" } "ARM64" { "arm64" } - default { Fail "unsupported Windows architecture: $nativeArchitecture (supported: x86, x64, ARM64)" } + default { Fail "unsupported Windows architecture: $nativeArchitecture (supported: x86, x64, ARM64)" "unsupported_arch" } } if (-not $InstallDir) { @@ -34,10 +52,10 @@ try { Invoke-WebRequest -UseBasicParsing -Uri "$BaseUrl/downloads/SHA256SUMS" -OutFile $manifestPath $manifest = Get-Content -Raw $manifestPath $match = [regex]::Match($manifest, "(?m)^([a-f0-9]{64}) " + [regex]::Escape($artifact) + "$") - if (-not $match.Success) { Fail "release manifest has no valid checksum for $artifact" } + if (-not $match.Success) { Fail "release manifest has no valid checksum for $artifact" "manifest_missing" } $expected = $match.Groups[1].Value $actual = (Get-FileHash -Algorithm SHA256 $binaryPath).Hash.ToLowerInvariant() - if ($actual -ne $expected) { Fail "downloaded binary failed checksum verification" } + if ($actual -ne $expected) { Fail "downloaded binary failed checksum verification" "checksum_mismatch" } New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null $target = Join-Path $InstallDir "shell.exe" @@ -55,10 +73,15 @@ try { Write-Host " [Environment]::SetEnvironmentVariable('Path', `"$InstallDir;`" + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')" } & $target --version + Report "ok" Write-Host "" Write-Host "Next:" Write-Host " shell Run it in the background and print its browser link" Write-Host " shell help See the guided start, share, and stop flow" +} catch { + # Fail has already reported its own reason; anything else is unspecified. + if (-not $script:reported) { Report "failed" } + throw } finally { Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $temporaryDirectory } diff --git a/scripts/check-worker-routing.mjs b/scripts/check-worker-routing.mjs new file mode 100644 index 0000000..543fe6d --- /dev/null +++ b/scripts/check-worker-routing.mjs @@ -0,0 +1,83 @@ +/* + * A page the Worker never sees is never counted. Static assets are served + * before the Worker runs unless their path is in `assets.run_worker_first`, + * so a deployment config that lists fewer paths than the example config + * quietly drops those pages from the statistics dashboard. Production once + * served the web app, CLI, Refstream and platforms guides that way, and + * "Visited the site" was short by every one of their views and readers. + * + * The example config is the reference: a deployment config must route at + * least what it routes. Run before building, so a bad config costs nothing. + * + * node scripts/check-worker-routing.mjs + */ +import { readFileSync } from "node:fs"; + +const [referencePath, deploymentPath] = process.argv.slice(2); +if (!referencePath || !deploymentPath) { + console.error("usage: check-worker-routing.mjs "); + process.exit(2); +} + +const reference = workerFirstPaths(referencePath); +const deployment = workerFirstPaths(deploymentPath); +const missing = deployment === true || reference === true + ? [] + : reference.filter((path) => !deployment.includes(path)); +if (missing.length > 0) { + console.error(`${deploymentPath} does not route these paths through the Worker, so their views would never be counted:`); + for (const path of missing) console.error(` ${path}`); + console.error(`Add them to assets.run_worker_first, as in ${referencePath}.`); + process.exit(1); +} +console.log(`${deploymentPath} routes every counted path through the Worker.`); + +/** The `assets.run_worker_first` list, or true when the config routes everything through the Worker. */ +function workerFirstPaths(path) { + let config; + try { + config = JSON.parse(stripJsonc(readFileSync(path, "utf8"))); + } catch (error) { + console.error(`${path}: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + const paths = config?.assets?.run_worker_first; + if (paths === true) return true; + if (!Array.isArray(paths) || !paths.every((entry) => typeof entry === "string")) { + console.error(`${path}: assets.run_worker_first must be a list of paths, or true`); + process.exit(1); + } + return paths; +} + +/** Comments and trailing commas outside strings, which is all JSONC adds to JSON. */ +function stripJsonc(source) { + let output = ""; + let index = 0; + while (index < source.length) { + const char = source[index]; + if (char === '"') { + const end = closingQuote(source, index); + output += source.slice(index, end + 1); + index = end + 1; + } else if (char === "/" && source[index + 1] === "/") { + const end = source.indexOf("\n", index); + index = end === -1 ? source.length : end; + } else if (char === "/" && source[index + 1] === "*") { + const end = source.indexOf("*/", index + 2); + index = end === -1 ? source.length : end + 2; + } else { + output += char; + index += 1; + } + } + return output.replace(/,(\s*[\]}])/g, "$1"); +} + +function closingQuote(source, start) { + for (let index = start + 1; index < source.length; index += 1) { + if (source[index] === "\\") index += 1; + else if (source[index] === '"') return index; + } + return source.length - 1; +} diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index f1fdded..0e37256 100755 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -13,6 +13,11 @@ if [ ! -f "$config" ]; then exit 1 fi +# A page served straight from the assets binding never reaches the Worker and +# is never counted. Refuse a config that routes fewer paths through the Worker +# than the example does, before anything is built. +node "$repository_root/scripts/check-worker-routing.mjs" "$repository_root/wrangler.example.jsonc" "$config" + # Wrangler resolves `main` and `assets.directory` beside its config file. A # private config kept in another checkout would otherwise deploy that other # checkout while this script builds the current one. Stage only the config diff --git a/scripts/test-deploy-production.sh b/scripts/test-deploy-production.sh index 27bd37b..d88b1c2 100755 --- a/scripts/test-deploy-production.sh +++ b/scripts/test-deploy-production.sh @@ -9,7 +9,9 @@ fake_bin="$test_root/bin" command_log="$test_root/commands" config="$test_root/wrangler.production.jsonc" mkdir -p "$fake_bin" -printf 'production-test-config\n' > "$config" +# A production config routes at least what the example routes through the +# Worker; the example itself, with a comment, stands in for one. +{ printf '// production-test-config\n'; cat "$repository_root/wrangler.example.jsonc"; } > "$config" cat > "$fake_bin/npm" <<'SCRIPT' #!/bin/sh @@ -28,7 +30,7 @@ case "$4" in "$SHELL_ONLINE_TEST_REPOSITORY_ROOT"/.wrangler.production.*.jsonc) ;; *) printf 'config was not staged beside the source: %s\n' "$4" >&2; exit 43 ;; esac -test "$(cat "$4")" = "production-test-config" +cmp -s "$4" "$SHELL_ONLINE_TEST_CONFIG" shift 4 printf 'npx wrangler deploy --config %s\n' "${*:+ $*}" >> "$SHELL_ONLINE_TEST_COMMAND_LOG" SCRIPT @@ -37,6 +39,7 @@ chmod 755 "$fake_bin/npm" "$fake_bin/npx" SHELL_ONLINE_TEST_COMMAND_LOG="$command_log" \ SHELL_ONLINE_TEST_REPOSITORY_ROOT="$repository_root" \ +SHELL_ONLINE_TEST_CONFIG="$config" \ SHELL_ONLINE_WRANGLER_CONFIG="$config" \ PATH="$fake_bin:$PATH" \ sh "$repository_root/scripts/deploy-production.sh" @@ -50,6 +53,7 @@ set +e SHELL_ONLINE_TEST_VERIFY_FAIL=1 \ SHELL_ONLINE_TEST_COMMAND_LOG="$command_log" \ SHELL_ONLINE_TEST_REPOSITORY_ROOT="$repository_root" \ +SHELL_ONLINE_TEST_CONFIG="$config" \ SHELL_ONLINE_WRANGLER_CONFIG="$config" \ PATH="$fake_bin:$PATH" \ sh "$repository_root/scripts/deploy-production.sh" @@ -69,4 +73,22 @@ if SHELL_ONLINE_TEST_COMMAND_LOG="$command_log" \ fi test ! -s "$command_log" +# A config that serves a documentation page from the assets binding would +# leave that page out of the statistics; the deploy must refuse it before +# building anything. +short_config="$test_root/short.jsonc" +grep -v '"/app/\*",' "$config" > "$short_config" +: > "$command_log" +if SHELL_ONLINE_TEST_COMMAND_LOG="$command_log" \ + SHELL_ONLINE_TEST_REPOSITORY_ROOT="$repository_root" \ + SHELL_ONLINE_TEST_CONFIG="$short_config" \ + SHELL_ONLINE_WRANGLER_CONFIG="$short_config" \ + PATH="$fake_bin:$PATH" \ + sh "$repository_root/scripts/deploy-production.sh" 2>"$test_root/short.err"; then + printf 'Deployment unexpectedly accepted a config that skips the Worker for /app/*.\n' >&2 + exit 1 +fi +grep -q '/app/\*' "$test_root/short.err" +test ! -s "$command_log" + echo "production deployment guard tests passed" diff --git a/scripts/test-install.sh b/scripts/test-install.sh index 23de7bd..1d7d9d8 100755 --- a/scripts/test-install.sh +++ b/scripts/test-install.sh @@ -1,6 +1,11 @@ #!/bin/sh set -eu +# The installer reports its outcome to its base URL, which for most of these +# scenarios is the real one. Keep it quiet; one scenario below turns it back +# on against a mock curl to check what it would send. +export SHELL_ONLINE_INSTALL_REPORT=0 + test_root=$(mktemp -d "${TMPDIR:-/tmp}/shell-online-install-test.XXXXXX") trap 'rm -rf "$test_root"' EXIT HUP INT TERM @@ -130,4 +135,32 @@ chmod 0755 "$shadow_dir/shell" output=$(PATH=$shadow_dir:$PATH run_installer "$shadow_install" env) assert_contains "$output" "Warning: shell currently resolves to $shadow_dir/shell" +# The installer's last word: one request with the outcome and the binary name, +# through curl when it has it, and nothing at all when told to keep quiet. +report_bin=$test_root/report-bin +report_log=$test_root/report.log +mkdir -p "$report_bin" +printf '%s\n' '#!/bin/sh' 'printf "%s\\n" "$*" >> "$SHELL_ONLINE_TEST_REPORT_LOG"' 'exit 22' > "$report_bin/curl" +chmod 0755 "$report_bin/curl" +: > "$report_log" +if output=$(SHELL_ONLINE_INSTALL_REPORT=1 SHELL_ONLINE_TEST_REPORT_LOG=$report_log \ + SHELL_ONLINE_BASE_URL=https://installer.invalid SHELL_ONLINE_INSTALL_DIR=$test_root/report-install \ + PATH=$report_bin:$PATH sh "$installer" 2>&1); then + printf 'Installer with a failing download unexpectedly succeeded.\n' >&2 + exit 1 +fi +assert_contains "$output" "download failed" +assert_contains "$(cat "$report_log")" "https://installer.invalid/install/report?outcome=download_failed&platform=shell-" +: > "$report_log" +if output=$(SHELL_ONLINE_INSTALL_REPORT=0 SHELL_ONLINE_TEST_REPORT_LOG=$report_log \ + SHELL_ONLINE_BASE_URL=https://installer.invalid SHELL_ONLINE_INSTALL_DIR=$test_root/report-install \ + PATH=$report_bin:$PATH sh "$installer" 2>&1); then + printf 'Installer with a failing download unexpectedly succeeded.\n' >&2 + exit 1 +fi +if grep -q "install/report" "$report_log"; then + printf 'Installer reported its outcome although SHELL_ONLINE_INSTALL_REPORT=0.\n' >&2 + exit 1 +fi + printf 'Installer integration scenarios passed.\n' diff --git a/shared/stats-copy.ts b/shared/stats-copy.ts new file mode 100644 index 0000000..86384a4 --- /dev/null +++ b/shared/stats-copy.ts @@ -0,0 +1,253 @@ +import type { StatsSnapshot } from "./stats"; +import { DAY_MS } from "./stats-snapshot"; + +/* + * The words on the statistics dashboard that are computed from its numbers: + * the sentence at the top of each panel, the chip beside a headline figure, + * and the names and formats everything is shown in. No DOM in here, so the + * copy can be tested as plainly as the figures it describes. + */ + +export const numberFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 }); +export const integerFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }); + +export function ratio(numerator: number, denominator: number): number { + return denominator === 0 ? 0 : Math.min(1, numerator / denominator); +} + +export function formatPercent(value: number): string { + return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`; +} + +export function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return "0s"; + if (seconds < 60) return `${Math.round(seconds)}s`; + if (seconds < 3_600) return `${Math.round(seconds / 60)}m`; + if (seconds < 86_400) return `${numberFormatter.format(seconds / 3_600)}h`; + return `${numberFormatter.format(seconds / 86_400)}d`; +} + +export function humanize(value: string): string { + const aliases: Record = { + cli: "CLI", + web: "Web", + direct: "Direct", + internal: "Internal", + hacker_news: "Hacker News", + task_exit: "Task exited", + persistent_task_exit: "Persistent task exited", + disconnected_timeout: "Disconnected timeout", + never_started: "Never started", + expired: "Expired", + session_full: "Session was full", + typed_into_read_only: "Typed into a read-only session", + viewer_read_only: "Read-only", + viewer_rejected: "Viewer turned away", + input_denied: "Input refused", + install_outcome: "Installer outcome", + machine_linked: "Linked a machine", + session_registered: "Registered a session", + command_sent: "Sent a command to a machine", + vault_created: "Created a vault", + invite_created: "Sent an invite", + invite_accepted: "Accepted an invite", + feedback_sent: "Sent feedback", + ok: "Installed", + failed: "Failed, unspecified", + unsupported_os: "Unsupported OS", + unsupported_arch: "Unsupported architecture", + no_home: "HOME not set", + install_dir_relative: "Install directory not absolute", + install_dir_colon: "Install directory has a colon", + install_dir_create: "Could not create install directory", + install_dir_unwritable: "Install directory not writable", + temp_dir: "Could not create temp directory", + temp_dir_unwritable: "Temp directory not writable", + download_failed: "Download failed", + no_downloader: "No curl or wget", + manifest_html: "Manifest came back as HTML", + manifest_missing: "No checksum in manifest", + manifest_invalid: "Invalid checksum in manifest", + no_sha_tool: "No sha256sum or shasum", + checksum_mismatch: "Checksum mismatch", + write_failed: "Could not write executable", + remote_input: "Remote input", + cta_click: "Sign-up click", + signup_nav: "Sign up free (nav)", + signup_hero: "Sign up free (hero)", + signup_team: "Manage a team", + signup_footer: "Web app (footer)", + not_found: "Not found (404)", + unknown_path: "Unknown path (served the landing page)", + landing: "Landing", + docs: "Docs", + docs_app: "Docs · Web app", + docs_cli: "Docs · CLI", + docs_platforms: "Docs · Platforms", + docs_mobile: "Docs · Mobile", + docs_refstream: "Docs · Refstream", + docs_reliability: "Docs · Reliability", + docs_security: "Docs · Security", + docs_e2ee: "Docs · E2EE", + docs_docker: "Docs · Docker", + docs_self_hosting: "Docs · Self-hosting", + session: "Shared terminal", + installer_download: "Installer fetched", + binary_download: "Install completed", + share_opened: "Opened in a browser", + viewer_connected: "Viewer connected", + viewer_disconnected: "Viewer disconnected", + collaboration_started: "Typed from a browser", + session_created: "Session created", + session_started: "Session started", + session_ended: "Session ended", + page_view: "Page view", + skill_download: "Skill fetched", + stats_view: "Dashboard view", + posix: "Install script (POSIX)", + powershell: "Install script (PowerShell)", + darwin_arm64: "macOS arm64", + darwin_amd64: "macOS amd64", + linux_arm64: "Linux arm64", + linux_amd64: "Linux amd64", + bot: "Crawlers", + desktop: "Desktop", + mobile: "Phone", + tablet: "Tablet", + unknown: "Unknown", + google: "Google", + github: "GitHub", + reddit: "Reddit", + x: "X", + app: "Web app", + newsletter: "Newsletter or email", + product_hunt: "Product Hunt", + linkedin: "LinkedIn", + youtube: "YouTube", + discord: "Discord", + slack: "Slack", + mastodon: "Mastodon", + bluesky: "Bluesky", + podcast: "Podcast", + other: "Other sites", + }; + const normalized = value.replace(/-/g, "_"); + if (aliases[normalized]) return aliases[normalized]; + const platform = normalized.match(/^(darwin|windows|linux|freebsd|openbsd|netbsd|dragonfly|solaris)_([a-z0-9]+)$/); + if (platform) { + const names: Record = { darwin: "macOS", windows: "Windows", linux: "Linux", freebsd: "FreeBSD", openbsd: "OpenBSD", netbsd: "NetBSD", dragonfly: "DragonFly", solaris: "Solaris" }; + return `${names[platform[1]]} ${platform[2]}`; + } + return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (character) => character.toUpperCase()); +} + +/** A session outcome as the end of a sentence that begins "the sessions that ended". */ +export function outcomePhrase(outcome: string): string { + const phrases: Record = { + task_exit: "did so because the task exited", + persistent_task_exit: "did so because a persistent task exited", + disconnected_timeout: "timed out after the host disconnected", + never_started: "never started", + expired: "expired", + }; + return phrases[outcome] ?? `ended as “${humanize(outcome)}”`; +} + +/* + * One sentence per section, computed from the same figures the section + * shows, so the reader gets the finding before the numbers. Each guards its + * ratios: a range with no sessions says so instead of dividing by zero. + */ +export function funnelInsight(snapshot: StatsSnapshot): string { + const figures = snapshot.figures; + const people = snapshot.uniques; + const parts: string[] = []; + if (figures.siteViews === 0) { + parts.push("No page views from browsers in this range."); + } else { + const visitors = people.configured ? ` from ${integerFormatter.format(people.surfaces.site.unique)} visitor${people.surfaces.site.unique === 1 ? "" : "s"}` : ""; + const crawlers = figures.crawlerViews === 0 ? "" : `, and ${integerFormatter.format(figures.crawlerViews)} by crawlers kept out`; + parts.push(`${integerFormatter.format(figures.siteViews)} views by people${visitors}${crawlers}.`); + if (figures.installCopies > 0) parts.push(`${integerFormatter.format(figures.installCopies)} copied an install command.`); + const runs = figures.installerRuns; + parts.push(runs === 0 + ? "Nobody ran the installer." + : `${integerFormatter.format(runs)} installer run${runs === 1 ? "" : "s"}, ${integerFormatter.format(figures.installs)} completed (${formatPercent(ratio(figures.installs, runs))}).`); + } + if (figures.sessionsStarted === 0) { + parts.push("No session started."); + } else { + const machines = people.configured ? ` on ${integerFormatter.format(people.surfaces.cli.unique)} machine${people.surfaces.cli.unique === 1 ? "" : "s"}` : ""; + if (figures.neverStarted > 0) parts.push(`${integerFormatter.format(figures.neverStarted)} more ${figures.neverStarted === 1 ? "was" : "were"} created but never connected.`); + const opened = formatPercent(ratio(figures.sharesOpened, figures.sessionsStarted)); + const typed = figures.sharesOpened === 0 ? "" : `, and ${formatPercent(ratio(figures.collaborations, figures.sharesOpened))} of those were typed into`; + parts.push(`${integerFormatter.format(figures.sessionsStarted)} session${figures.sessionsStarted === 1 ? "" : "s"} started${machines}; ${opened} were opened in a browser${typed}.`); + } + return parts.join(" "); +} + +export function trafficInsight(snapshot: StatsSnapshot): string { + const metrics = snapshot.metrics; + const audiences = snapshot.audiences.views; + const total = metrics.landingViews + metrics.docsViews; + if (total === 0) return "No landing or documentation views in this range."; + const parts = [`${formatPercent(ratio(audiences.crawlers, total))} of ${integerFormatter.format(total)} views were crawlers.`]; + const referrers = snapshot.breakdowns.referrers; + const referred = referrers.reduce((sum, item) => sum + item.value, 0); + if (referrers.length > 0 && referred > 0) { + parts.push(`Top source of landing visits: ${humanize(referrers[0].label)} (${formatPercent(ratio(referrers[0].value, referred))}).`); + } + if (metrics.unknownPaths > 0) { + parts.push(`${integerFormatter.format(metrics.unknownPaths)} request${metrics.unknownPaths === 1 ? "" : "s"} hit a path the site does not know and got the landing page.`); + } + return parts.join(" "); +} + +export function sessionsInsight(snapshot: StatsSnapshot): string { + const metrics = snapshot.metrics; + const days = Math.max(1, (snapshot.generatedAt - snapshot.rangeStart) / DAY_MS); + if (metrics.sessionsCreated === 0) return "No session was created in this range."; + const perDay = metrics.sessionsStarted / days; + const parts = [ + `${integerFormatter.format(metrics.sessionsCreated)} created, ${integerFormatter.format(metrics.sessionsStarted)} started (${formatPercent(ratio(metrics.sessionsStarted, metrics.sessionsCreated))}), about ${perDay >= 10 ? integerFormatter.format(perDay) : numberFormatter.format(perDay)} a day.`, + ]; + const outcomes = snapshot.breakdowns.outcomes; + const ended = outcomes.reduce((sum, item) => sum + item.value, 0); + if (ended > 0 && outcomes.length > 0) { + parts.push(`${formatPercent(ratio(outcomes[0].value, ended))} of the ${integerFormatter.format(ended)} that ended ${outcomePhrase(outcomes[0].label)}; a session lasted ${formatDuration(metrics.averageDurationSeconds)} on average.`); + } + if (metrics.sharesOpened > 0) { + parts.push(`A link waited ${formatDuration(metrics.averageSecondsToOpen)} for its first open on average.`); + } + if (metrics.viewersRejected > 0) { + parts.push(`${integerFormatter.format(metrics.viewersRejected)} viewer${metrics.viewersRejected === 1 ? " was" : "s were"} turned away by a full, expired or unknown session.`); + } + return parts.join(" "); +} + +export interface DeltaChip { + tone: "up" | "down" | "flat"; + text: string; + /** The figure it moved from, for the chip's tooltip. */ + title: string; +} + +/** + * How a headline figure moved against the period before. A period with + * nothing in it makes any change infinite, so it says "new" instead; a + * change past tenfold is shown as a multiple; under half a percent is the + * same. Null when there is nothing to compare with. + */ +export function deltaChip(current: number, before: number | null, priorLabel: string): DeltaChip | null { + if (before === null || priorLabel === "") return null; + const title = `${integerFormatter.format(before)} in ${priorLabel}`; + if (before === 0 && current === 0) return { tone: "flat", text: "same", title }; + if (before === 0) return { tone: "up", text: "new", title }; + const change = (current - before) / before; + if (Math.abs(change) < 0.005) return { tone: "flat", text: "same", title }; + const arrow = change > 0 ? "▲" : "▼"; + const text = Math.abs(change) >= 10 + ? `${arrow}${numberFormatter.format(current / before)}×` + : `${arrow}${Math.round(Math.abs(change) * 100)}%`; + return { tone: change > 0 ? "up" : "down", text, title }; +} diff --git a/shared/stats-snapshot.ts b/shared/stats-snapshot.ts index 386f3b5..e699e90 100644 --- a/shared/stats-snapshot.ts +++ b/shared/stats-snapshot.ts @@ -4,7 +4,12 @@ import { VISITOR_MEMORY_DAYS, isUniqueSurface, type StatsAccounts, + type StatsAudience, + type StatsAudiences, type StatsBreakdownItem, + type StatsComparison, + type StatsFigures, + type StatsFunnelExclusion, type StatsFunnelStep, type StatsRange, type StatsRetentionCohort, @@ -62,6 +67,13 @@ export interface UniqueDayRow extends Record { unique_count: number; } +/** Machines seen installing, and how many of them went on to a first session. */ +export interface InstallConversionRow extends Record { + installers: number | null; + matured: number | null; + started: number | null; +} + /** One visitor on one day, with the day they were first seen, for the cohort grids. */ export interface RetentionRow extends Record { surface: string; @@ -70,18 +82,49 @@ export interface RetentionRow extends Record { day: number; } +/** One event's count for one device class in the range. */ +export interface AudienceRow extends Record { + event: string; + target: string; + device: string; + count: number; +} + +/** Distinct visitor hashes seen on one surface in a period. */ +export interface PeriodUniqueRow extends Record { + surface: string; + unique_count: number; +} + +/** The period of equal length before the range, for comparison. */ +export interface PreviousPeriodRows { + rangeStart: number; + summary: MetricSummaryRow[]; + byDevice: AudienceRow[]; + uniques: PeriodUniqueRow[]; +} + export interface StatsSnapshotRows { summary: MetricSummaryRow[]; + byDevice: AudienceRow[]; + /** Null on the all-time range. */ + previous: PreviousPeriodRows | null; trend: MetricTrendRow[]; devices: BreakdownRow[]; referrers: BreakdownRow[]; clients: BreakdownRow[]; + openedDevices: BreakdownRow[]; + typedDevices: BreakdownRow[]; live: LivePresenceRow; collectingSince: number | null; uniques: UniqueSummaryRow[]; uniqueDays: UniqueDayRow[]; retention: RetentionRow[]; + /** Null when people are not counted. */ + installConversion: InstallConversionRow | null; uniquesConfigured: boolean; + /** Midnight UTC of the earliest visitor day still kept, or null when there is none. */ + uniquesSince: number | null; } /** Midnight UTC of the day that contains `at`. */ @@ -103,9 +146,8 @@ export function buildStatsSnapshot( rangeStart: number, accounts: StatsAccounts = null, ): StatsSnapshot { - const total = (event: string, target?: string): number => rows.summary - .filter((row) => row.event === event && (target === undefined || row.target === target)) - .reduce((sum, row) => sum + Number(row.count), 0); + const total = (event: string, target?: string): number => sumCounts(rows.summary, event, target); + const average = (event: string): number => averageValue(rows.summary, event); const ended = rows.summary.filter((row) => row.event === "session_ended"); const endedCount = ended.reduce((sum, row) => sum + Number(row.count), 0); const durationSum = ended.reduce((sum, row) => sum + Number(row.value_sum), 0); @@ -122,6 +164,8 @@ export function buildStatsSnapshot( const binaryDownloads = total("binary_download"); const trendStepMs = statsTrendStep(range, now - rangeStart); const uniques = buildUniques(rows); + const audiences = buildAudiences(rows.byDevice); + const figures = buildFigures(rows.summary, audiences); const metrics: StatsSnapshot["metrics"] = { activeSessions: Math.max(0, Number(rows.live.active_sessions)), @@ -129,8 +173,14 @@ export function buildStatsSnapshot( sessionsCreated, sessionsStarted, sharesOpened, + sharesOpenedReadOnly: total("share_opened", "viewer_read_only"), viewerConnections: total("viewer_connected"), collaborations, + viewersRejected: total("viewer_rejected"), + inputDenied: total("input_denied"), + averageSecondsToOpen: average("share_opened"), + averageSecondsToType: average("collaboration_started"), + averageViewerSeconds: average("viewer_disconnected"), landingViews, docsViews, terminalViews: total("page_view", "session"), @@ -140,6 +190,8 @@ export function buildStatsSnapshot( installs: total("installer_download"), skillDownloads: total("skill_download"), binaryDownloads, + installsReported: total("install_outcome", "ok"), + installFailuresReported: total("install_outcome") - total("install_outcome", "ok"), copies: total("copy"), averageDurationSeconds: endedCount === 0 ? 0 : durationSum / endedCount, longestDurationSeconds: ended.reduce( @@ -168,7 +220,17 @@ export function buildStatsSnapshot( signup: ratio(ctaClicks, landingViews), installed: ratio(binaryDownloads, landingViews), }, - funnel: buildFunnel(metrics, uniques), + figures, + audiences, + previous: buildComparison(rows.previous, rangeStart, rows.collectingSince, uniques), + funnel: buildFunnel(figures, audiences, uniques), + installConversion: uniques.configured && rows.installConversion + ? { + installers: Number(rows.installConversion.installers ?? 0), + matured: Number(rows.installConversion.matured ?? 0), + started: Number(rows.installConversion.started ?? 0), + } + : null, uniques, retention: { weeks: RETENTION_WEEKS, @@ -189,6 +251,10 @@ export function buildStatsSnapshot( ].sort((left, right) => right.value - left.value), outcomes: targetBreakdown(rows.summary, "session_ended"), pages: targetBreakdown(rows.summary, "page_view"), + openedDevices: breakdown(rows.openedDevices), + typedDevices: breakdown(rows.typedDevices), + rejections: targetBreakdown(rows.summary, "viewer_rejected"), + installOutcomes: targetBreakdown(rows.summary, "install_outcome"), }, targets: rows.summary.map((row): StatsTargetMetric => ({ event: row.event, @@ -202,76 +268,194 @@ export function buildStatsSnapshot( }; } +function sumCounts(rows: MetricSummaryRow[], event: string, target?: string): number { + return rows + .filter((row) => row.event === event && (target === undefined || row.target === target)) + .reduce((sum, row) => sum + Number(row.count), 0); +} + +/** The mean of an event's value over its occurrences, or zero when there were none. */ +function averageValue(rows: MetricSummaryRow[], event: string): number { + const matching = rows.filter((row) => row.event === event); + const count = matching.reduce((sum, row) => sum + Number(row.count), 0); + if (count === 0) return 0; + return matching.reduce((sum, row) => sum + Number(row.value_sum), 0) / count; +} + +const AUDIENCE_EVENTS: Record boolean> = { + views: (event, target) => event === "page_view" && (target === "landing" || target.startsWith("docs")), + installer: (event) => event === "installer_download", + installs: (event) => event === "binary_download", + viewers: (event) => event === "viewer_connected", +}; + +/** Which audience a device class belongs to: the classifier's classes, folded to three that matter and a rest. */ +export function audienceOf(device: string): keyof StatsAudience { + if (device === "desktop" || device === "mobile" || device === "tablet") return "browsers"; + if (device === "cli") return "tools"; + if (device === "bot") return "crawlers"; + return "unknown"; +} + +export function buildAudiences(rows: AudienceRow[]): StatsAudiences { + const empty = (): StatsAudience => ({ browsers: 0, tools: 0, crawlers: 0, unknown: 0 }); + const audiences: StatsAudiences = { views: empty(), installer: empty(), installs: empty(), viewers: empty() }; + for (const row of rows) { + for (const key of Object.keys(AUDIENCE_EVENTS) as (keyof StatsAudiences)[]) { + if (AUDIENCE_EVENTS[key](row.event, row.target)) audiences[key][audienceOf(row.device)] += Number(row.count); + } + } + return audiences; +} + +/** Everything but crawlers: a binary went to a person's machine, whatever fetched it. */ +export function installsCompleted(installs: StatsAudience): number { + return installs.browsers + installs.tools + installs.unknown; +} + +export function buildFigures(summary: MetricSummaryRow[], audiences: StatsAudiences): StatsFigures { + return { + siteViews: audiences.views.browsers, + crawlerViews: audiences.views.crawlers, + ctaClicks: sumCounts(summary, "cta_click"), + installCopies: sumCounts(summary, "copy", "install") + sumCounts(summary, "copy", "brew_install") + sumCounts(summary, "copy", "source_build"), + installerRuns: audiences.installer.tools, + installs: installsCompleted(audiences.installs), + sessionsStarted: sumCounts(summary, "session_started"), + neverStarted: sumCounts(summary, "session_ended", "never_started"), + sharesOpened: sumCounts(summary, "share_opened"), + sharesOpenedWritable: sumCounts(summary, "share_opened") - sumCounts(summary, "share_opened", "viewer_read_only"), + collaborations: sumCounts(summary, "collaboration_started"), + }; +} + +/** + * The period before the range, when there is one worth comparing with: not + * on the all-time range, and not when it reaches back before collection + * began, since a comparison with an empty period says everything doubled. + * People are compared only when they were counted for the whole of it. + */ +export function buildComparison( + previous: PreviousPeriodRows | null, + rangeStart: number, + collectingSince: number | null, + uniques: StatsUniques, +): StatsComparison | null { + if (!previous || collectingSince === null || previous.rangeStart < collectingSince) return null; + const audiences = buildAudiences(previous.byDevice); + const covered = uniques.configured && uniques.since !== null && uniques.since <= dayStart(previous.rangeStart); + let people: StatsComparison["people"] = null; + if (covered) { + people = Object.fromEntries(UNIQUE_SURFACES.map((surface) => [surface, 0])) as Record; + for (const row of previous.uniques) { + if (isUniqueSurface(row.surface)) people[row.surface] = Number(row.unique_count); + } + } + return { + rangeStart: previous.rangeStart, + rangeEnd: rangeStart, + figures: buildFigures(previous.summary, audiences), + people, + }; +} + /* * The path from a first look to a first keystroke, one row per step, each - * saying what it counts. A step's count is an event total; its unique figure - * is how many distinct people were behind it, on the surfaces that count - * people. The two are shown side by side rather than blended, because a - * hundred page views from one crawler and a hundred visitors are different - * news. + * saying what it counts and what it leaves out. A step's count is of requests + * that a person is plausibly behind; its unique figure is how many distinct + * people were, on the surfaces that count people. Crawlers are listed beside + * the step they were kept out of, because a hundred page views from one + * crawler and a hundred visitors are different news. */ export function buildFunnel( - metrics: StatsSnapshot["metrics"], + figures: StatsFigures, + audiences: StatsAudiences, uniques: StatsUniques, ): StatsFunnelStep[] { const people = (surface: UniqueSurface): number | null => uniques.configured ? uniques.surfaces[surface].unique : null; + const excluded = (entries: StatsFunnelExclusion[]): StatsFunnelExclusion[] => entries.filter((entry) => entry.count > 0); return [ { key: "visited", label: "Visited the site", - count: metrics.landingViews + metrics.docsViews, + count: figures.siteViews, unique: people("site"), - note: "Landing and documentation page views. Crawlers count as views, not as people.", + note: "Landing and documentation page views from a browser.", + excluded: excluded([ + { label: "by crawlers", count: audiences.views.crawlers }, + { label: "by tools", count: audiences.views.tools + audiences.views.unknown }, + ]), basis: null, }, { key: "signup", label: "Clicked Sign up", - count: metrics.ctaClicks, + count: figures.ctaClicks, + unique: null, + note: "Any Sign up free or Web app link on the landing page. Most accounts start elsewhere: the app, an invite, the CLI.", + excluded: [], + basis: "visited", + }, + { + key: "copied", + label: "Copied an install command", + count: figures.installCopies, unique: null, - note: "Any Sign up free or Web app link on the landing page.", + note: "The curl, Homebrew or source-build command copied on the landing page: intent, before a terminal is involved.", + excluded: [], basis: "visited", }, { key: "installer", - label: "Fetched the installer", - count: metrics.installs, + label: "Ran the installer", + count: figures.installerRuns, unique: people("install"), - note: "Requests for the install script. Reading it counts; so does piping it to sh.", + note: "The install script fetched by curl or wget, which is how it is run.", + excluded: excluded([ + { label: "read in a browser", count: audiences.installer.browsers }, + { label: "by crawlers", count: audiences.installer.crawlers }, + { label: "unknown", count: audiences.installer.unknown }, + ]), basis: "visited", }, { key: "installed", label: "Completed an install", - count: metrics.binaryDownloads, + count: figures.installs, unique: null, - note: "Release binaries served, the installer's last step. Homebrew and source builds are not in this number.", + note: "A release binary served, the installer's last step. Homebrew and source builds are not in this number.", + excluded: excluded([{ label: "by crawlers", count: audiences.installs.crawlers }]), basis: "installer", }, { key: "session", label: "Started a session", - count: metrics.sessionsStarted, + count: figures.sessionsStarted, unique: people("cli"), note: "A shell command connected its process to the relay. Not a share of the step before: sessions come from every install to date.", + excluded: excluded([{ label: "created but never connected", count: figures.neverStarted }]), basis: null, }, { key: "opened", label: "Opened it in a browser", - count: metrics.sharesOpened, + count: figures.sharesOpened, unique: people("viewer"), note: "Sessions whose link was opened at least once, by anyone, the owner included.", + excluded: [], basis: "session", }, { key: "typed", label: "Typed from a browser", - count: metrics.collaborations, + count: figures.collaborations, unique: null, - note: "Sessions that received at least one keystroke from a browser.", + note: "Sessions that received at least one keystroke from a browser. Read-only sessions cannot, so they are not in the share.", + excluded: excluded([{ label: "opened read-only, typing impossible", count: figures.sharesOpened - figures.sharesOpenedWritable }]), basis: "opened", + basisCount: figures.sharesOpenedWritable, + basisLabel: "opened sessions that allow typing", }, ]; } @@ -342,11 +526,28 @@ function buildUniques(rows: StatsSnapshotRows): StatsUniques { return { configured: rows.uniquesConfigured, memoryDays: VISITOR_MEMORY_DAYS, + since: rows.uniquesConfigured ? rows.uniquesSince : null, surfaces, daily: [...days.values()].sort((left, right) => left.day - right.day), }; } +/** + * The day people counts start from, when that is after the range began, or + * null when people cover the whole range. Events are counted from the first + * event and people from the day the visitor salt was set, for at most + * VISITOR_MEMORY_DAYS, so a 30-day range can hold thirty days of events and + * one day of people. A people figure shown beside an event count over such a + * range has to say so, or 29,333 views next to 84 people reads as nonsense. + */ +export function peopleCountedSince( + uniques: Pick, + rangeStart: number, +): number | null { + if (!uniques.configured || uniques.since === null) return null; + return uniques.since > dayStart(rangeStart) ? uniques.since : null; +} + export function statsRangeStart( range: StatsRange, now: number, diff --git a/shared/stats.ts b/shared/stats.ts index 3f7e075..37507ae 100644 --- a/shared/stats.ts +++ b/shared/stats.ts @@ -15,6 +15,22 @@ export const VISITOR_MEMORY_DAYS = 120; /** How many weekly cohorts the retention grids show. */ export const RETENTION_WEEKS = 8; +/** How long after installing a machine has to start a session to count as converted. */ +export const INSTALL_CONVERSION_DAYS = 7; + +/** + * Machines followed from a binary download to a first session, by address: + * the one join the dashboard can make between the installer and the CLI. + */ +export interface StatsInstallConversion { + /** Machines whose first install was in the range. */ + installers: number; + /** Of those, installed at least INSTALL_CONVERSION_DAYS ago, so their window has fully elapsed. */ + matured: number; + /** Of the matured, started a session within the window. */ + started: number; +} + export interface StatsSeriesPoint { at: number; sessions: number; @@ -59,10 +75,85 @@ export interface StatsUniques { /** False until the Worker has a visitor salt; every count is then zero. */ configured: boolean; memoryDays: number; + /** + * Midnight UTC of the earliest day anyone was counted, or null when nobody + * has been. Events are counted from the first event and people from the + * day the salt was set, so a range can hold more days of events than of + * people; peopleCountedSince says when a figure has to say so. + */ + since: number | null; surfaces: Record; daily: StatsUniqueDay[]; } +/** + * One event's requests by who made them, from the user agent. A browser is a + * person looking; a tool is a person's machine doing what it was told; a + * crawler is neither, and is kept out of every figure about people. + */ +export interface StatsAudience { + /** Desktop, tablet and phone browsers. */ + browsers: number; + /** curl, wget and the shell CLI. */ + tools: number; + /** Crawlers, monitors and headless browsers that say so. */ + crawlers: number; + /** No user agent, or one the classifier could not place. */ + unknown: number; +} + +export interface StatsAudiences { + /** Landing and documentation page views. */ + views: StatsAudience; + /** Requests for the install script. */ + installer: StatsAudience; + /** Release binaries served. */ + installs: StatsAudience; + /** Browser connections to a shared terminal. */ + viewers: StatsAudience; +} + +/** + * The figures the page is built on, each with the crawlers taken out, so a + * step of the funnel and its comparison with the period before mean the same + * thing. The raw event totals stay in metrics and the ledger. + */ +export interface StatsFigures { + /** Landing and documentation views from browsers: people looking. */ + siteViews: number; + /** The same pages fetched by self-identified crawlers. */ + crawlerViews: number; + ctaClicks: number; + /** The curl, Homebrew or source-build command copied on the landing page: intent before the terminal. */ + installCopies: number; + /** Install script fetches by curl or wget: the installer actually run, not read. */ + installerRuns: number; + /** Release binaries served to anything but a crawler: an install completed. */ + installs: number; + sessionsStarted: number; + /** Sessions that ended without the host ever connecting: a blocked WebSocket, usually. */ + neverStarted: number; + sharesOpened: number; + /** Opened sessions that allow typing: the only ones a keystroke can come from. */ + sharesOpenedWritable: number; + collaborations: number; +} + +/** The same figures for the period of equal length before the range. */ +export interface StatsComparison { + rangeStart: number; + rangeEnd: number; + figures: StatsFigures; + /** Distinct people per surface in that period, or null when people were not counted for all of it. */ + people: Record | null; +} + +/** Requests a funnel step leaves out, so the reader sees what was not counted and why. */ +export interface StatsFunnelExclusion { + label: string; + count: number; +} + export interface StatsFunnelStep { key: string; label: string; @@ -71,6 +162,11 @@ export interface StatsFunnelStep { unique: number | null; /** What the count is, in one sentence, so nobody has to guess. */ note: string; + /** What the count leaves out: crawler views, an installer read in a browser. */ + excluded: StatsFunnelExclusion[]; + /** When the share is of part of the basis step, that part and its name: typed, of the opened sessions that allow typing. */ + basisCount?: number; + basisLabel?: string; /** * The step this one is a share of, or null when it is its own population: * sessions in a range come from every install ever made, not from this @@ -103,6 +199,8 @@ export interface StatsAccountStats { activeInRange: number; newByDay: { day: number; count: number }[]; cohorts: StatsRetentionCohort[]; + /** Things done in the app in the range, by kind: machines linked, commands sent. Counts of things, not of accounts. */ + events: Record; } export type StatsAccounts = StatsAccountStats | { error: string } | null; @@ -120,8 +218,20 @@ export interface StatsSnapshot { sessionsCreated: number; sessionsStarted: number; sharesOpened: number; + /** Of those, sessions the owner made read-only, where nobody can type. */ + sharesOpenedReadOnly: number; viewerConnections: number; collaborations: number; + /** Browsers that opened a link and were turned away: full, expired or unknown session. */ + viewersRejected: number; + /** Viewers who tried to type into a read-only session, once each. */ + inputDenied: number; + /** From creation to the first browser open, over sessions that were opened. */ + averageSecondsToOpen: number; + /** From the first open to the first keystroke, over sessions that were typed into. */ + averageSecondsToType: number; + /** How long a viewer stayed connected, over viewers that disconnected. */ + averageViewerSeconds: number; landingViews: number; docsViews: number; terminalViews: number; @@ -136,6 +246,10 @@ export interface StatsSnapshot { skillDownloads: number; /** Release binaries served: the installer's last step, so a completed install. */ binaryDownloads: number; + /** Installers that reported finishing, from the script itself. */ + installsReported: number; + /** Installers that reported failing, by their own account. */ + installFailuresReported: number; copies: number; averageDurationSeconds: number; longestDurationSeconds: number; @@ -151,7 +265,13 @@ export interface StatsSnapshot { /** Completed installs per landing view. */ installed: number; }; + figures: StatsFigures; + audiences: StatsAudiences; + /** Null on the all-time range, or when the period before is older than collection. */ + previous: StatsComparison | null; funnel: StatsFunnelStep[]; + /** Null until people are counted. */ + installConversion: StatsInstallConversion | null; uniques: StatsUniques; retention: StatsRetention; accounts: StatsAccounts; @@ -164,6 +284,14 @@ export interface StatsSnapshot { downloads: StatsBreakdownItem[]; outcomes: StatsBreakdownItem[]; pages: StatsBreakdownItem[]; + /** Sessions opened, by the device class of the first viewer. */ + openedDevices: StatsBreakdownItem[]; + /** Sessions typed into, by the device class of the first typist. */ + typedDevices: StatsBreakdownItem[]; + /** Why viewers were turned away. */ + rejections: StatsBreakdownItem[]; + /** How installs ended, as the scripts reported. */ + installOutcomes: StatsBreakdownItem[]; }; targets: StatsTargetMetric[]; } diff --git a/tests/analytics.test.ts b/tests/analytics.test.ts index f55717d..ae70c4a 100644 --- a/tests/analytics.test.ts +++ b/tests/analytics.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it, vi } from "vitest"; import { binaryDownloadTarget, + campaignSource, classifyClient, classifyDevice, classifyReferrer, documentTarget, hasVisitorSalt, + installReportOutcome, isDocumentNavigation, + machineKey, normalizeAnalyticsRecord, requestAnalyticsContext, requestVisitor, @@ -69,6 +72,22 @@ describe("analytics", () => { .toBe("internal"); expect(classifyReferrer("https://example.com/private/path", "https://shell.online")) .toBe("other"); + expect(classifyReferrer("https://app.shell.online/sessions", "https://shell.online")).toBe("app"); + expect(classifyReferrer("https://app.example.test/", "https://example.test")).toBe("app"); + }); + + it("takes a named campaign over a hidden referrer, and only a named one", () => { + expect(campaignSource(new URL("https://shell.online/?utm_source=hn&utm_medium=post"))).toBe("hacker_news"); + expect(campaignSource(new URL("https://shell.online/?ref=producthunt"))).toBe("product_hunt"); + expect(campaignSource(new URL("https://shell.online/?utm_source=