Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/downloads.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,21 +58,25 @@ jobs:
script: powershell
runs-on: ${{ matrix.os }}
timeout-minutes: 10
# Forty-eight installs a day from fresh runner addresses would read as
# people on the statistics dashboard. The check agent on every request,
# the scripts' own included, tells the site this is a monitor.
env:
ORIGIN: ${{ inputs.origin || 'https://shell.online' }}
SHELL_ONLINE_INSTALL_CHECK: "1"
steps:
- name: Install with the documented command
if: matrix.script == 'posix'
run: |
export SHELL_ONLINE_INSTALL_DIR="$RUNNER_TEMP/shell-online"
curl -fsSL "$ORIGIN/install" | sh
curl -fsSL -A shell.online-install-check "$ORIGIN/install" | sh
"$SHELL_ONLINE_INSTALL_DIR/shell" --version
- name: Install with the documented command
if: matrix.script == 'powershell'
shell: pwsh
run: |
$env:SHELL_ONLINE_INSTALL_DIR = Join-Path $env:RUNNER_TEMP "shell-online"
Invoke-RestMethod "$env:ORIGIN/install.ps1" | Invoke-Expression
Invoke-RestMethod -UserAgent shell.online-install-check "$env:ORIGIN/install.ps1" | Invoke-Expression
& (Join-Path $env:SHELL_ONLINE_INSTALL_DIR "shell.exe") --version

report:
Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,46 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve

## Unreleased

### Added

- The statistics dashboard now carries the accounts, laid out to be read in
one pass: how many there are and how that moved against the period before,
how many signed up and how many opened the app, how many of those had
signed up earlier, a line of sign-ups and use for every day since the first
account, what accounts did in the app, how many days each of them has been
in it, and which sign-up weeks came back. It sits directly under the funnel,
which ends at a first keystroke, because an account is what the funnel is
for.
- Accounts of your own are left out of every account figure, named by
`STATS_EXCLUDE` on the accounts app: addresses, or domains and their
subdomains. The team's accounts are the most active there are and were
always going to use the product, so leaving them in makes a quiet week look
like a good one. The dashboard says how many it left out, so the figure can
be checked rather than taken on trust. What those accounts do in the app is
counted apart from what customers do and never reported.

### Fixed

- The scheduled download check installed on three fresh GitHub runners every
half hour and was counted as installs, installer runs, installer outcomes
and new machines, which is where a dashboard day of ninety installs and
fifty-six new machines came from. Both install scripts now take
`SHELL_ONLINE_INSTALL_CHECK=1`, which puts a check user agent on every
request and reports nothing; the workflow sets it, and the site counts
that agent, and monitors in general, as crawlers.
- HTTP libraries and PowerShell's web cmdlets were classified as desktop
browsers, so a Node script or a Windows install read as a person reading
the installer. They are tools now, and a Windows install counts as a run.
- The installs tile drew the sessions line. The trend now carries installs
and started sessions as their own series, crawlers left out of every line.
- "New" people are measured from the day a surface's people were first
counted, per surface, and while a whole range has not yet passed since
that day the split is replaced by the day it becomes meaningful. Machine
rows keyed the old way are dropped once, so they do not sit in the cohorts
as machines that never came back.
- Pages a browser fetched ahead of time (prefetch, prerender) are not views.
- The 24h range says that people are counted by UTC day, so over two days.

## [0.16.0] — 2026-09-15

### Changed
Expand Down
3 changes: 3 additions & 0 deletions app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ TRUST_PROXY=0
# Optional, 32+ characters. The same value goes on the relay Worker as
# APP_STATS_TOKEN so its statistics dashboard can read account counts.
# STATS_TOKEN=
# Accounts left out of every statistics figure: your own, not customers'.
# Addresses, or domains and their subdomains, separated by commas or spaces.
# STATS_EXCLUDE=

# Optional invitation email. Defaults to SendGrid.
MAIL_PROVIDER=sendgrid
Expand Down
1 change: 1 addition & 0 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ The server uses:
| `FEEDBACK_TO` | Optional address that feedback sent from the app is forwarded to |
| `TRUST_PROXY` | Set to `1` only behind a trusted proxy |
| `STATS_TOKEN` | Optional, 32+ characters: lets the relay's statistics dashboard read account counts and sign-up cohorts |
| `STATS_EXCLUDE` | Optional: addresses and domains whose accounts are left out of every statistics figure, for your own team's accounts. Comma or space separated; an entry with a local part matches that address, one without matches the domain and its subdomains. The dashboard reports how many accounts it left out |

See [`.env.example`](.env.example) for the complete development configuration.

Expand Down
45 changes: 45 additions & 0 deletions app/server/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2728,6 +2728,26 @@ describe("feedback", () => {
expect(kept.orgId).toBeTruthy();
});

/*
* The dashboard reports what customers did. A thing done by one of our own
* accounts is counted apart, so a week of our own testing cannot read as a
* week of use.
*/
it("counts a thing one of our own accounts did apart from the rest", async () => {
handle = createApp({
store,
verifyIdToken: verifyIdToken as never,
allowedOrigins: [ORIGIN],
excludedAccounts: ["ours.example"],
});
expect((await call("POST", "/api/feedback", { auth: await idToken(), body: message })).status).toBe(201);
expect((await call("POST", "/api/feedback", {
auth: await idToken({ sub: "uid-ours", email: "dev@ours.example", name: "Dev" }),
body: message,
})).status).toBe(201);
expect(await store.appEvents(0)).toEqual([{ event: "feedback_sent", count: 1 }]);
});

it("refuses without a signed-in user", async () => {
const posted = await call("POST", "/api/feedback", { body: message });
expect(posted.status).toBe(401);
Expand Down Expand Up @@ -2788,4 +2808,29 @@ describe("account figures for the statistics dashboard", () => {
expect(JSON.stringify(answer.body)).not.toContain("uid-1");
expect(JSON.stringify(answer.body)).not.toContain("ana@example.com");
});

/*
* Our own accounts are the most active there are and were always going to
* use the product. Left in, a quiet week reads as a good one, so they are
* out of the counts and only their number is reported.
*/
it("leaves our own accounts out of the figures, and says how many it left out", async () => {
handle = createApp({
store,
verifyIdToken: verifyIdToken as never,
allowedOrigins: [ORIGIN],
statsToken: TOKEN,
excludedAccounts: ["ours.example"],
});
expect((await call("GET", "/api/org", { auth: await idToken() })).status).toBe(200);
expect((await call("GET", "/api/org", {
auth: await idToken({ sub: "uid-ours", email: "dev@ours.example", name: "Dev" }),
})).status).toBe(200);

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, excluded: 1 });
expect(answer.body.cohorts[0].size).toBe(1);
expect(JSON.stringify(answer.body)).not.toContain("ours.example");
});
});
31 changes: 21 additions & 10 deletions app/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,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 { excludedAccountFilter } from "./lib/internal-accounts";
import { accountStats, dayStart, isStatsRange, rangeStart } from "./routes/stats";
import { timingSafeEqual } from "node:crypto";
import { callerAddress, rateLimiter } from "./lib/rate-limit";
Expand Down Expand Up @@ -88,6 +89,11 @@ export interface AppOptions {
* exist. At least 32 characters; see readConfig.
*/
statsToken?: string;
/**
* Accounts the statistics dashboard leaves out of every figure: ours, not
* customers'. Addresses and domains; see internal-accounts.ts.
*/
excludedAccounts?: string[];
/**
* Serves the built client for anything that is not an API route. Present
* only in a deployment that serves the app and the API together; in
Expand Down Expand Up @@ -336,6 +342,8 @@ export function createApp(options: AppOptions) {
const mailer = options.mailer ?? logMailer();
/* The first allowed origin is the web app's; see readConfig. */
const webOrigin = options.webOrigin ?? allowedOrigins[0] ?? "";
/* Whether an address is one of ours, for the statistics only. */
const isInternalAccount = excludedAccountFilter(options.excludedAccounts ?? []);
const credentialLimit = rateLimiter(CREDENTIAL_BUCKET);
const generalLimit = rateLimiter(GENERAL_BUCKET);
const feedbackLimit = rateLimiter(FEEDBACK_BUCKET);
Expand Down Expand Up @@ -393,9 +401,12 @@ export function createApp(options: AppOptions) {
* 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.
*
* The address is passed so the store can record whether this was one of
* ours; it is read for that and nothing else, and never stored.
*/
const track = (event: AppEvent): void => {
void store.recordAppEvent(event).catch(() => undefined);
const track = (event: AppEvent, email: string): void => {
void store.recordAppEvent(event, undefined, isInternalAccount(email)).catch(() => undefined);
};

/* The CLI authenticates with an opaque access token issued by this service. */
Expand Down Expand Up @@ -503,7 +514,7 @@ export function createApp(options: AppOptions) {
label: String(body.label ?? "shell cli").slice(0, 80),
machineId,
});
track("machine_linked");
track("machine_linked", result.email);
return send(response, 200, {
access_token: tokens.accessToken,
refresh_token: tokens.refreshToken,
Expand Down Expand Up @@ -580,7 +591,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");
if (resolved.joined && invite) track("invite_accepted", identity.email);
const described = await describeOrganization(store, resolved.membership);
return send(response, described.status, {
...(described.body as Record<string, unknown>),
Expand Down Expand Up @@ -621,7 +632,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");
if (result.status < 300) track("invite_created", membership.email);
return send(response, result.status, result.body);
}

Expand Down Expand Up @@ -993,7 +1004,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");
track("vault_created", identity.email);
return send(response, 201, { vault: stored ? vaultForApi(stored) : null });
}

Expand Down Expand Up @@ -1146,7 +1157,7 @@ export function createApp(options: AppOptions) {
result.session.name || result.session.command,
);
}
track("session_registered");
track("session_registered", token.email);
return send(response, 201, { session: sessionForApi(result.session) });
}

Expand Down Expand Up @@ -1377,7 +1388,7 @@ export function createApp(options: AppOptions) {
createdAt: Date.now(),
};
await store.putCommand(queued);
track("command_sent");
track("command_sent", identity.email);
return send(response, 202, { command: queued });
}

Expand Down Expand Up @@ -1564,7 +1575,7 @@ export function createApp(options: AppOptions) {
log,
);
if (!result.ok) return send(response, result.status, { error: result.error });
track("feedback_sent");
track("feedback_sent", membership.email);
return send(response, 201, { feedback: { id: result.value.id, at: result.value.at } });
}

Expand All @@ -1581,7 +1592,7 @@ export function createApp(options: AppOptions) {
const range = isStatsRange(requested) ? requested : "7d";
const now = Date.now();
return send(response, 200, accountStats(
await store.accountActivity(),
await store.accountActivity(isInternalAccount),
range,
now,
await store.appEvents(dayStart(rangeStart(range, now))),
Expand Down
1 change: 1 addition & 0 deletions app/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const server = createAccountsServer({
mailer: createMailer(config.mail),
feedbackTo: config.feedbackTo,
statsToken: config.statsToken,
excludedAccounts: config.excludedAccounts,
serveClient: config.clientDir
? staticFiles(config.clientDir, config.identity.issuer)
: undefined,
Expand Down
13 changes: 13 additions & 0 deletions app/server/lib/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,19 @@ describe("withoutCredentials", () => {
});
});

describe("readConfig statistics settings", () => {
it("reads the accounts the statistics leave out, and excludes nobody by default", () => {
expect(readConfig(MINIMAL).excludedAccounts).toEqual([]);
expect(readConfig({ ...MINIMAL, STATS_EXCLUDE: "Ours.example, someone@mail.example" }).excludedAccounts)
.toEqual(["ours.example", "someone@mail.example"]);
});

it("refuses a statistics token short enough to guess", () => {
expect(() => readConfig({ ...MINIMAL, STATS_TOKEN: "short" })).toThrow(/STATS_TOKEN/);
expect(readConfig({ ...MINIMAL, STATS_TOKEN: "t".repeat(32) }).statsToken).toBe("t".repeat(32));
});
});

describe("allowedOriginsFor", () => {
/*
* A page on somebody's own machine must not be able to call production with
Expand Down
8 changes: 8 additions & 0 deletions app/server/lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parseExcludedAccounts } from "./internal-accounts";
import type { IssuerSettings } from "./oidc-token";

/**
Expand Down Expand Up @@ -65,6 +66,12 @@ export interface Config {
* figures. Absent means that route does not exist.
*/
statsToken?: string;
/**
* Accounts the statistics dashboard leaves out of every figure: ours, not
* customers'. Addresses and domains, read from STATS_EXCLUDE; see
* internal-accounts.ts for why it is configuration rather than source.
*/
excludedAccounts: string[];
}

export class ConfigError extends Error {}
Expand Down Expand Up @@ -260,5 +267,6 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): Config {
},
feedbackTo: env.FEEDBACK_TO?.trim() || undefined,
statsToken,
excludedAccounts: parseExcludedAccounts(env.STATS_EXCLUDE),
};
}
53 changes: 53 additions & 0 deletions app/server/lib/internal-accounts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { excludedAccountFilter, isExcludedAccount, parseExcludedAccounts } from "./internal-accounts";

describe("parseExcludedAccounts", () => {
it("reads a list however it was written, and nothing from an unset variable", () => {
expect(parseExcludedAccounts("a@example.com, b.com; @c.org\nD@Example.com")).toEqual([
"a@example.com",
"b.com",
"@c.org",
"d@example.com",
]);
expect(parseExcludedAccounts(undefined)).toEqual([]);
expect(parseExcludedAccounts(" ")).toEqual([]);
});
});

describe("isExcludedAccount", () => {
const rules = parseExcludedAccounts("ours.example, @tools.example, someone@mail.example");

it("excludes a domain and its subdomains, written either way", () => {
expect(isExcludedAccount(rules, "a@ours.example")).toBe(true);
expect(isExcludedAccount(rules, "a@eng.ours.example")).toBe(true);
expect(isExcludedAccount(rules, "a@tools.example")).toBe(true);
expect(isExcludedAccount(rules, "a@notours.example")).toBe(false);
/* A domain rule must not match a company whose name merely ends the same way. */
expect(isExcludedAccount(rules, "a@theirours.example")).toBe(false);
});

it("excludes one named address without touching the rest of its provider", () => {
expect(isExcludedAccount(rules, "someone@mail.example")).toBe(true);
expect(isExcludedAccount(rules, "SomeOne@Mail.Example")).toBe(true);
expect(isExcludedAccount(rules, "someone.else@mail.example")).toBe(false);
});

it("sees through a +tag on either side", () => {
expect(isExcludedAccount(rules, "someone+shell@mail.example")).toBe(true);
expect(isExcludedAccount(parseExcludedAccounts("someone+old@mail.example"), "someone@mail.example")).toBe(true);
});

it("excludes nobody on an empty list or a missing address", () => {
expect(isExcludedAccount([], "a@ours.example")).toBe(false);
expect(isExcludedAccount(rules, "")).toBe(false);
expect(isExcludedAccount(rules, undefined)).toBe(false);
expect(isExcludedAccount(rules, "not-an-address")).toBe(false);
});
});

describe("excludedAccountFilter", () => {
it("is a predicate, and excludes nobody when nothing is configured", () => {
expect(excludedAccountFilter(parseExcludedAccounts("ours.example"))("a@ours.example")).toBe(true);
expect(excludedAccountFilter([])("a@ours.example")).toBe(false);
});
});
Loading
Loading