From b20632c366934f1a0fbf8f5b6ffa409eac558266 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 17 Oct 2024 09:13:34 -0700 Subject: [PATCH] more precise telemetry types --- docs/telemetry.md | 61 ------------------------------------------ docs/telemetry.md.js | 32 ++++++++++++++++++++++ src/telemetry.ts | 44 ++++-------------------------- src/telemetryData.d.ts | 44 ++++++++++++++++++++++++++++++ test/telemetry-test.ts | 12 ++++----- 5 files changed, 87 insertions(+), 106 deletions(-) delete mode 100644 docs/telemetry.md create mode 100644 docs/telemetry.md.js create mode 100644 src/telemetryData.d.ts diff --git a/docs/telemetry.md b/docs/telemetry.md deleted file mode 100644 index 57e615232..000000000 --- a/docs/telemetry.md +++ /dev/null @@ -1,61 +0,0 @@ -# Telemetry - -Observable Framework collects anonymous usage data to help us improve the product. This data is sent to Observable and is not shared with third parties. Telemetry data is covered by [Observable’s privacy policy](https://observablehq.com/privacy-policy). - -You can [opt-out of telemetry](#disabling-telemetry) by setting the `OBSERVABLE_TELEMETRY_DISABLE` environment variable to `true`. - -## What is collected? - -The following data is collected: - -```ts run=false -type TelemetryIds = { - session: uuid; // random, held in memory for the duration of the process - device: uuid; // persists to ~/.observablehq - project: string; // one-way hash of private salt + repository URL or cwd -}; - -type TelemetryEnvironment = { - version: string; // version from package.json - userAgent: string; // npm_config_user_agent - node: string; // node.js version - systemPlatform: string; // linux, darwin, win32, … - systemRelease: string; // 20.04, 11.2.3, … - systemArchitecture: string; // x64, arm64, … - cpuCount: number; // number of cpu cores - cpuModel: string | null; // cpu model name - cpuSpeed: number | null; // cpu speed in MHz - memoryInMb: number; // truncated to mb - isCI: string | boolean; // inside CI heuristic, name or false - isDocker: boolean; // inside Docker heuristic - isWSL: boolean; // inside WSL heuristic -}; - -type TelemetryTime = { - now: number; // performance.now - timeOrigin: number; // performance.timeOrigin - timeZoneOffset: number; // minutes from UTC -}; - -type TelemetryData = { - event: "build" | "deploy" | "preview" | "signal"; - step?: "start" | "finish"; - [key: string]: unknown; -}; -``` - -To inspect telemetry data, set the `OBSERVABLE_TELEMETRY_DEBUG` environment variable to `true`. This will print the telemetry data to stderr instead of sending it to Observable. See [`telemetry.ts`](https://github.com/observablehq/framework/blob/main/src/telemetry.ts) for source code. - -## What is not collected? - -We never collect identifying or sensitive information, such as environment variables, file names or paths, or file contents. - -## Disabling telemetry - -Setting the `OBSERVABLE_TELEMETRY_DISABLE` environment variable to `true` disables telemetry collection entirely. For example: - -```sh -OBSERVABLE_TELEMETRY_DISABLE=true npm run build -``` - -Setting the `OBSERVABLE_TELEMETRY_DEBUG` environment variable to `true` also disables telemetry collection, instead printing telemetry data to stderr. Use this to inspect what telemetry data would be collected. diff --git a/docs/telemetry.md.js b/docs/telemetry.md.js new file mode 100644 index 000000000..714be02b4 --- /dev/null +++ b/docs/telemetry.md.js @@ -0,0 +1,32 @@ +import {readFile} from "node:fs/promises"; + +process.stdout.write(`# Telemetry + +Observable Framework collects anonymous usage data to help us improve the product. This data is sent to Observable and is not shared with third parties. Telemetry data is covered by [Observable’s privacy policy](https://observablehq.com/privacy-policy). + +You can [opt-out of telemetry](#disabling-telemetry) by setting the \`OBSERVABLE_TELEMETRY_DISABLE\` environment variable to \`true\`. + +## What is collected? + +The following data is collected: + +~~~ts run=false +${(await readFile("./src/telemetryData.d.ts", "utf-8")).trim()} +~~~ + +To inspect telemetry data, set the \`OBSERVABLE_TELEMETRY_DEBUG\` environment variable to \`true\`. This will print the telemetry data to stderr instead of sending it to Observable. See [\`telemetry.ts\`](https://github.com/observablehq/framework/blob/main/src/telemetry.ts) for source code. + +## What is not collected? + +We never collect identifying or sensitive information, such as environment variables, file names or paths, or file contents. + +## Disabling telemetry + +Setting the \`OBSERVABLE_TELEMETRY_DISABLE\` environment variable to \`true\` disables telemetry collection entirely. For example: + +~~~sh +OBSERVABLE_TELEMETRY_DISABLE=true npm run build +~~~ + +Setting the \`OBSERVABLE_TELEMETRY_DEBUG\` environment variable to \`true\` also disables telemetry collection, instead printing telemetry data to stderr. Use this to inspect what telemetry data would be collected. +`); diff --git a/src/telemetry.ts b/src/telemetry.ts index 7f012192c..c7cb8b4d4 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -1,4 +1,5 @@ import {exec} from "node:child_process"; +import type {UUID} from "node:crypto"; import {createHash, randomUUID} from "node:crypto"; import {readFile, writeFile} from "node:fs/promises"; import os from "node:os"; @@ -6,44 +7,9 @@ import {join} from "node:path/posix"; import {CliError} from "./error.js"; import type {Logger} from "./logger.js"; import {getObservableUiOrigin} from "./observableApiClient.js"; +import type {TelemetryData, TelemetryEnvironment, TelemetryIds, TelemetryTime} from "./telemetryData.js"; import {link, magenta} from "./tty.js"; -type uuid = ReturnType; - -type TelemetryIds = { - session: uuid | null; // random, held in memory for the duration of the process - device: uuid | null; // persists to ~/.observablehq - project: string | null; // one-way hash of private salt + repository URL or cwd -}; - -type TelemetryEnvironment = { - version: string; // version from package.json - userAgent: string; // npm_config_user_agent - node: string; // node.js version - systemPlatform: string; // linux, darwin, win32, ... - systemRelease: string; // 20.04, 11.2.3, ... - systemArchitecture: string; // x64, arm64, ... - cpuCount: number; // number of cpu cores - cpuModel: string | null; // cpu model name - cpuSpeed: number | null; // cpu speed in MHz - memoryInMb: number; // truncated to mb - isCI: string | boolean; // inside CI heuristic, name or false - isDocker: boolean; // inside Docker heuristic - isWSL: boolean; // inside WSL heuristic -}; - -type TelemetryTime = { - now: number; // performance.now - timeOrigin: number; // performance.timeOrigin - timeZoneOffset: number; // minutes from UTC -}; - -type TelemetryData = { - event: "build" | "deploy" | "preview" | "signal" | "login"; - step?: "start" | "finish" | "error"; - [key: string]: unknown; -}; - type TelemetryEffects = { logger: Logger; process: NodeJS.Process; @@ -79,7 +45,7 @@ export class Telemetry { private endpoint: URL; private timeZoneOffset = new Date().getTimezoneOffset(); private readonly _pending = new Set>(); - private _config: Promise> | undefined; + private _config: Promise> | undefined; private _ids: Promise | undefined; private _environment: Promise | undefined; @@ -142,7 +108,7 @@ export class Telemetry { process.on(name, signaled); } - private async getPersistentId(name: string, generator = randomUUID): Promise { + private async getPersistentId(name: string, generator = randomUUID): Promise { const {readFile, writeFile} = this.effects; const file = join(os.homedir(), ".observablehq"); if (!this._config) { @@ -213,7 +179,7 @@ export class Telemetry { } private async showBannerIfNeeded() { - let called: uuid | undefined; + let called: UUID | undefined; await this.getPersistentId("cli_telemetry_banner", () => (called = randomUUID())); if (called) { this.effects.logger.error( diff --git a/src/telemetryData.d.ts b/src/telemetryData.d.ts new file mode 100644 index 000000000..69b19c6d7 --- /dev/null +++ b/src/telemetryData.d.ts @@ -0,0 +1,44 @@ +import type {UUID} from "node:crypto"; + +export type TelemetryIds = { + session: UUID | null; // random, held in memory for the duration of the process + device: UUID | null; // persists to ~/.observablehq + project: string | null; // one-way hash of private salt + repository URL or cwd +}; + +export type TelemetryEnvironment = { + version: string; // version from package.json + userAgent: string; // npm_config_user_agent + node: string; // node.js version + systemPlatform: string; // linux, darwin, win32, ... + systemRelease: string; // 20.04, 11.2.3, ... + systemArchitecture: string; // x64, arm64, ... + cpuCount: number; // number of cpu cores + cpuModel: string | null; // cpu model name + cpuSpeed: number | null; // cpu speed in MHz + memoryInMb: number; // truncated to mb + isCI: string | boolean; // inside CI heuristic, name or false + isDocker: boolean; // inside Docker heuristic + isWSL: boolean; // inside WSL heuristic +}; + +export type TelemetryTime = { + now: number; // performance.now + timeOrigin: number; // performance.timeOrigin + timeZoneOffset: number; // minutes from UTC +}; + +export type TelemetryData = + | {event: "build"; step: "start"} + | {event: "build"; step: "finish"; pageCount: number} + | {event: "deploy"; step: "start"; force: boolean | null | "build" | "deploy"} + | {event: "deploy"; step: "finish"} + | {event: "deploy"; step: "error"} + | {event: "deploy"; buildManifest: "found" | "missing" | "error"} + | {event: "preview"; step: "start"} + | {event: "preview"; step: "finish"} + | {event: "preview"; step: "error"} + | {event: "signal"; signal: NodeJS.Signals} + | {event: "login"; step: "start"} + | {event: "login"; step: "finish"} + | {event: "login"; step: "error"; code: "expired" | "consumed" | "no-key" | `unknown-${string}`}; diff --git a/test/telemetry-test.ts b/test/telemetry-test.ts index ab435a4f3..b2c669a99 100644 --- a/test/telemetry-test.ts +++ b/test/telemetry-test.ts @@ -29,7 +29,7 @@ describe("telemetry", () => { it("sends data", async () => { Telemetry._instance = new Telemetry(noopEffects); - Telemetry.record({event: "build", step: "start", test: true}); + Telemetry.record({event: "build", step: "start"}); await Telemetry.instance.pending; agent.assertNoPendingInterceptors(); }); @@ -37,14 +37,14 @@ describe("telemetry", () => { it("shows a banner", async () => { const logger = new MockLogger(); const telemetry = new Telemetry({...noopEffects, logger, readFile: () => Promise.reject()}); - telemetry.record({event: "build", step: "start", test: true}); + telemetry.record({event: "build", step: "start"}); await telemetry.pending; logger.assertExactErrors([/Attention.*observablehq.com.*OBSERVABLE_TELEMETRY_DISABLE=true/s]); }); it("can be disabled", async () => { const telemetry = new Telemetry({...noopEffects, process: processMock({env: {OBSERVABLE_TELEMETRY_DISABLE: "1"}})}); - telemetry.record({event: "build", step: "start", test: true}); + telemetry.record({event: "build", step: "start"}); await telemetry.pending; assert.equal(agent.pendingInterceptors().length, 1); }); @@ -56,7 +56,7 @@ describe("telemetry", () => { logger, process: processMock({env: {OBSERVABLE_TELEMETRY_DEBUG: "1"}}) }); - telemetry.record({event: "build", step: "start", test: true}); + telemetry.record({event: "build", step: "start"}); await telemetry.pending; assert.equal(logger.errorLines.length, 1); assert.equal(logger.errorLines[0][0], "[telemetry]"); @@ -71,7 +71,7 @@ describe("telemetry", () => { process: processMock({env: {OBSERVABLE_TELEMETRY_DEBUG: "1"}}), writeFile: () => Promise.reject() }); - telemetry.record({event: "build", step: "start", test: true}); + telemetry.record({event: "build", step: "start"}); await telemetry.pending; assert.notEqual(logger.errorLines[0][1].ids.session, null); assert.equal(logger.errorLines[0][1].ids.device, null); @@ -86,7 +86,7 @@ describe("telemetry", () => { logger, process: processMock({env: {OBSERVABLE_TELEMETRY_ORIGIN: "https://invalid."}}) }); - telemetry.record({event: "build", step: "start", test: true}); + telemetry.record({event: "build", step: "start"}); await telemetry.pending; assert.equal(logger.errorLines.length, 0); assert.equal(agent.pendingInterceptors().length, 1);