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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve

## Unreleased

### 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
19 changes: 17 additions & 2 deletions public/install
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ set -eu
# 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.
#
# A run that is only a check, a monitor or a CI job, should set
# SHELL_ONLINE_INSTALL_CHECK=1: every request then carries the user agent
# shell.online-install-check, which the site counts as a monitor and not as
# a person, and nothing is reported.
curl_agent=
wget_agent=
if [ "${SHELL_ONLINE_INSTALL_CHECK:-0}" = 1 ]; then
curl_agent="-A shell.online-install-check"
wget_agent="-U shell.online-install-check"
fi

fail() {
printf 'shell.online: %s\n' "$1" >&2
Expand All @@ -15,6 +26,7 @@ fail() {

report() {
[ "${SHELL_ONLINE_INSTALL_REPORT:-1}" != 0 ] || return 0
[ -z "$curl_agent" ] || return 0
case "${base_url:-}" in
http://*|https://*) ;;
*) return 0 ;;
Expand Down Expand Up @@ -101,12 +113,15 @@ binary_url="$base_url/downloads/$binary_name"
download() {
source_url=$1
destination=$2
# The agent flags are two words or none, on purpose unquoted.
if command -v curl >/dev/null 2>&1; then
if ! curl -fsSL "$source_url" -o "$destination"; then
# shellcheck disable=SC2086
if ! curl -fsSL $curl_agent "$source_url" -o "$destination"; then
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
# shellcheck disable=SC2086
if ! wget -q $wget_agent "$source_url" -O "$destination"; then
fail "download failed: $source_url" download_failed
fi
else
Expand Down
12 changes: 10 additions & 2 deletions public/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@ $ErrorActionPreference = "Stop"
# 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.
# A run that is only a check, a monitor or a CI job, should set
# SHELL_ONLINE_INSTALL_CHECK=1: every request then carries the user agent
# shell.online-install-check, which the site counts as a monitor and not as
# a person, and nothing is reported.
$web = @{ UseBasicParsing = $true }
if ($env:SHELL_ONLINE_INSTALL_CHECK -eq "1") { $web.UserAgent = "shell.online-install-check" }

$script:reported = $false
function Report([string]$Outcome) {
if ($env:SHELL_ONLINE_INSTALL_REPORT -eq "0") { return }
if ($web.ContainsKey("UserAgent")) { return }
if ($BaseUrl -notmatch '^https?://') { return }
$script:reported = $true
$platform = if ($script:artifact) { $script:artifact } else { "unknown" }
Expand Down Expand Up @@ -48,8 +56,8 @@ $manifestPath = Join-Path $temporaryDirectory "SHA256SUMS"

try {
New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null
Invoke-WebRequest -UseBasicParsing -Uri "$BaseUrl/downloads/$artifact" -OutFile $binaryPath
Invoke-WebRequest -UseBasicParsing -Uri "$BaseUrl/downloads/SHA256SUMS" -OutFile $manifestPath
Invoke-WebRequest @web -Uri "$BaseUrl/downloads/$artifact" -OutFile $binaryPath
Invoke-WebRequest @web -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" "manifest_missing" }
Expand Down
15 changes: 15 additions & 0 deletions scripts/test-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,19 @@ if grep -q "install/report" "$report_log"; then
exit 1
fi

# A check run says so on every request and reports nothing, even when
# reporting is otherwise on.
: > "$report_log"
if output=$(SHELL_ONLINE_INSTALL_REPORT=1 SHELL_ONLINE_INSTALL_CHECK=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 "$(cat "$report_log")" "-A shell.online-install-check https://installer.invalid/downloads/shell-"
if grep -q "install/report" "$report_log"; then
printf 'A check run reported its outcome.\n' >&2
exit 1
fi

printf 'Installer integration scenarios passed.\n'
30 changes: 24 additions & 6 deletions shared/stats-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ export interface AudienceRow extends Record<string, string | number | null> {
count: number;
}

/** The earliest day people were counted on one surface. */
export interface SurfaceSinceRow extends Record<string, string | number | null> {
surface: string;
minimum: number | null;
}

/** Distinct visitor hashes seen on one surface in a period. */
export interface PeriodUniqueRow extends Record<string, string | number | null> {
surface: string;
Expand Down Expand Up @@ -125,6 +131,8 @@ export interface StatsSnapshotRows {
uniquesConfigured: boolean;
/** Midnight UTC of the earliest visitor day still kept, or null when there is none. */
uniquesSince: number | null;
/** The same, per surface. */
uniquesSinceBySurface: SurfaceSinceRow[];
}

/** Midnight UTC of the day that contains `at`. */
Expand Down Expand Up @@ -507,13 +515,18 @@ export function buildRetentionCohorts(

function buildUniques(rows: StatsSnapshotRows): StatsUniques {
const surfaces = Object.fromEntries(
UNIQUE_SURFACES.map((surface): [UniqueSurface, StatsUniqueCount] => [surface, { unique: 0, new: 0, returning: 0 }]),
UNIQUE_SURFACES.map((surface): [UniqueSurface, StatsUniqueCount] => [surface, { unique: 0, new: 0, returning: 0, since: null }]),
) as Record<UniqueSurface, StatsUniqueCount>;
for (const row of rows.uniques) {
if (!isUniqueSurface(row.surface)) continue;
const unique = Number(row.unique_count);
const fresh = Math.min(unique, Number(row.new_count));
surfaces[row.surface] = { unique, new: fresh, returning: unique - fresh };
surfaces[row.surface] = { ...surfaces[row.surface], unique, new: fresh, returning: unique - fresh };
}
if (rows.uniquesConfigured) {
for (const row of rows.uniquesSinceBySurface) {
if (isUniqueSurface(row.surface) && row.minimum !== null) surfaces[row.surface].since = Number(row.minimum);
}
}
const days = new Map<number, StatsUniqueDay>();
for (const row of rows.uniqueDays) {
Expand Down Expand Up @@ -541,11 +554,14 @@ function buildUniques(rows: StatsSnapshotRows): StatsUniques {
* range has to say so, or 29,333 views next to 84 people reads as nonsense.
*/
export function peopleCountedSince(
uniques: Pick<StatsUniques, "configured" | "since">,
uniques: Pick<StatsUniques, "configured" | "since"> & { surfaces?: Record<UniqueSurface, Pick<StatsUniqueCount, "since">> },
rangeStart: number,
surface?: UniqueSurface,
): number | null {
if (!uniques.configured || uniques.since === null) return null;
return uniques.since > dayStart(rangeStart) ? uniques.since : null;
if (!uniques.configured) return null;
const since = surface === undefined ? uniques.since : uniques.surfaces?.[surface]?.since ?? null;
if (since === null) return null;
return since > dayStart(rangeStart) ? since : null;
}

export function statsRangeStart(
Expand Down Expand Up @@ -578,17 +594,19 @@ function buildTrend(
const end = Math.floor(now / stepMs) * stepMs;
const points = new Map<number, StatsSeriesPoint>();
for (let at = start; at <= end; at += stepMs) {
points.set(at, { at, sessions: 0, shares: 0, collaborations: 0, pageViews: 0 });
points.set(at, { at, sessions: 0, started: 0, shares: 0, collaborations: 0, pageViews: 0, installs: 0 });
}
for (const row of rows) {
const at = Math.floor(Number(row.bucket) / stepMs) * stepMs;
const point = points.get(at);
if (!point) continue;
const count = Number(row.count);
if (row.event === "session_created") point.sessions += count;
if (row.event === "session_started") point.started += count;
if (row.event === "share_opened") point.shares += count;
if (row.event === "collaboration_started") point.collaborations += count;
if (row.event === "page_view") point.pageViews += count;
if (row.event === "binary_download") point.installs += count;
}
return Array.from(points.values()).slice(-180);
}
Expand Down
13 changes: 13 additions & 0 deletions shared/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,16 @@ export interface StatsInstallConversion {

export interface StatsSeriesPoint {
at: number;
/** Sessions created. */
sessions: number;
/** Sessions whose host connected. */
started: number;
shares: number;
collaborations: number;
/** Landing and documentation views, crawlers left out. */
pageViews: number;
/** Release binaries served, crawlers left out. */
installs: number;
}

export interface StatsBreakdownItem {
Expand All @@ -61,6 +67,13 @@ export interface StatsUniqueCount {
new: number;
/** Of those, seen before the range began. */
returning: number;
/**
* Midnight UTC of the earliest day this surface's people were counted, or
* null. Surfaces start on different days: machines were re-keyed after
* visitors were first counted, so a "new" machine and a "new" visitor are
* measured from different starts.
*/
since: number | null;
}

export interface StatsUniqueDay {
Expand Down
33 changes: 33 additions & 0 deletions tests/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,39 @@ describe("analytics", () => {
});
});

it("counts monitors as crawlers and HTTP libraries as tools, never as people", () => {
for (const agent of ["shell.online-downloads-check", "shell.online-install-check", "Better Uptime Bot", "Pingdom.com_bot", "Checkly/1.0", "Site24x7", "Datadog Synthetics"]) {
expect(classifyDevice(agent)).toBe("bot");
expect(classifyClient(agent)).toBe("bot");
}
for (const agent of [
"node",
"node-fetch/1.0",
"undici",
"Go-http-client/1.1",
"python-requests/2.31.0",
"Java/17.0.2",
"okhttp/4.12.0",
"axios/1.6.0",
"Mozilla/5.0 (Windows NT 10.0; Microsoft Windows 10.0.19045; en-US) PowerShell/7.4.1",
"Mozilla/5.0 (Windows NT; Windows NT 10.0; en-US) WindowsPowerShell/5.1.19041.1",
]) {
expect(classifyDevice(agent)).toBe("cli");
expect(classifyClient(agent)).toBe("tool");
}
expect(classifyDevice("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/129.0 Safari/537.36")).toBe("desktop");
expect(classifyDevice("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15 Version/17.6 Safari/605.1.15")).toBe("desktop");
expect(classifyDevice("")).toBe("unknown");
});

it("does not count a page a browser fetched ahead of time", () => {
const headers = { Accept: "text/html", "Sec-Fetch-Dest": "document" };
expect(isDocumentNavigation(new Request("https://shell.online/", { headers }))).toBe(true);
expect(isDocumentNavigation(new Request("https://shell.online/", { headers: { ...headers, "Sec-Purpose": "prefetch" } }))).toBe(false);
expect(isDocumentNavigation(new Request("https://shell.online/", { headers: { ...headers, "Sec-Purpose": "prefetch;prerender" } }))).toBe(false);
expect(isDocumentNavigation(new Request("https://shell.online/", { headers: { ...headers, Purpose: "prefetch" } }))).toBe(false);
});

it("reduces user agents and referrers to coarse categories", () => {
expect(classifyDevice("shell/0.3.4")).toBe("cli");
expect(classifyClient("shell/0.3.4")).toBe("shell/0.3.4");
Expand Down
1 change: 1 addition & 0 deletions tests/stats-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function rows(overrides: Partial<StatsSnapshotRows> = {}): StatsSnapshotRows {
installConversion: null,
uniquesConfigured: true,
uniquesSince: dayStart(now - 20 * DAY_MS),
uniquesSinceBySurface: [],
...overrides,
};
}
Expand Down
51 changes: 51 additions & 0 deletions tests/stats-database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
collectStatsRows,
HOUR_MS,
initializeStatsSchema,
MACHINE_KEY_DAY,
migrateStatsData,
parseStatsPresence,
parseStatsRecord,
recordStatsEvent,
Expand Down Expand Up @@ -101,6 +103,19 @@ describe("the dashboard's database", () => {
const day = collectStatsRows(sql, "24h", false, now);
expect(day.rows.summary).toEqual([]);
expect(day.rows.previous).toMatchObject({ rangeStart: now - 2 * DAY_MS, summary: [{ count: 2 }] });

/* Crawlers stay out of the charts, and installs and started sessions join them. */
recordStatsEvent(sql, event(daysAgo(1), "page_view", "landing", { device: "bot", client: "bot" }));
recordStatsEvent(sql, event(daysAgo(1), "binary_download", "linux-amd64", { device: "cli", client: "curl" }));
recordStatsEvent(sql, event(daysAgo(1), "binary_download", "linux-amd64", { device: "bot", client: "bot" }));
recordStatsEvent(sql, event(daysAgo(1), "session_started", "cli", { device: "cli", client: "shell/0.16.0" }));
const charted = collectStatsRows(sql, "7d", false, now).rows.trend;
const hour = Math.floor(daysAgo(1) / HOUR_MS) * HOUR_MS;
expect(charted).toEqual([
{ bucket: hour, event: "binary_download", count: 1 },
{ bucket: hour, event: "page_view", count: 2 },
{ bucket: hour, event: "session_started", count: 1 },
]);
});

it("splits the counted events by device class and ranks dimensions by count", () => {
Expand Down Expand Up @@ -140,6 +155,10 @@ describe("the dashboard's database", () => {

const { rows } = collectStatsRows(sql, "7d", true, now);
expect(rows.uniquesSince).toBe(dayStart(daysAgo(10)));
expect(rows.uniquesSinceBySurface).toEqual([
{ surface: "cli", minimum: dayStart(daysAgo(1)) },
{ surface: "site", minimum: dayStart(daysAgo(10)) },
]);
expect(bySurface(rows.uniques)).toEqual([
{ surface: "cli", unique_count: 1, new_count: 1 },
{ surface: "site", unique_count: 2, new_count: 1 },
Expand Down Expand Up @@ -192,6 +211,38 @@ describe("the dashboard's database", () => {
expect(collectStatsRows(sql, "all", true, now).rows.installConversion).toMatchObject({ installers: 6 });
});

it("drops the machine rows keyed the old way once, and never again", () => {
const sql = freshDatabase();
/* A database from before the mark existed. */
sql.exec("DELETE FROM dashboard_marks");
const cutover = MACHINE_KEY_DAY + 6 * HOUR_MS;
recordStatsEvent(sql, event(cutover - DAY_MS, "session_created", "cli", { device: "cli", visitor: hash("o") }));
recordStatsEvent(sql, event(cutover, "binary_download", "darwin-arm64", { device: "cli", visitor: hash("p") }));
recordStatsEvent(sql, event(cutover - DAY_MS, "page_view", "landing", { visitor: hash("v") }));
recordStatsEvent(sql, event(cutover + DAY_MS, "session_created", "cli", { device: "cli", visitor: hash("n") }));
/* Seen before and after the cut-over: the row survives, its first day with it. */
recordStatsEvent(sql, event(cutover - DAY_MS, "session_created", "cli", { device: "cli", visitor: hash("k") }));
recordStatsEvent(sql, event(cutover + DAY_MS, "session_created", "cli", { device: "cli", visitor: hash("k") }));

migrateStatsData(sql);
const left = sql.exec<{ surface: string; visitor: string; first_day: number }>(
"SELECT surface, visitor, first_day FROM visitors ORDER BY surface, visitor",
).toArray();
expect(left).toEqual([
{ surface: "cli", visitor: hash("k"), first_day: dayStart(cutover - DAY_MS) },
{ surface: "cli", visitor: hash("n"), first_day: dayStart(cutover + DAY_MS) },
{ surface: "site", visitor: hash("v"), first_day: dayStart(cutover - DAY_MS) },
]);
expect(sql.exec<{ day: number }>("SELECT day FROM visitor_days WHERE visitor = ? ORDER BY day", hash("k")).toArray())
.toEqual([{ day: dayStart(cutover + DAY_MS) }]);

/* Done once: a later old-looking row is left alone. */
recordStatsEvent(sql, event(cutover - DAY_MS, "session_created", "cli", { device: "cli", visitor: hash("z") }));
initializeStatsSchema(sql);
migrateStatsData(sql);
expect(sql.exec<{ visitor: string }>("SELECT visitor FROM visitors WHERE visitor = ?", hash("z")).toArray()).toHaveLength(1);
});

it("keeps a live presence lease until it ends, and drops it when told", () => {
const sql = freshDatabase();
const key = (letter: string): string => letter.repeat(22);
Expand Down
Loading
Loading