Skip to content

Commit

Permalink
more precise telemetry types
Browse files Browse the repository at this point in the history
  • Loading branch information
mbostock committed Oct 17, 2024
1 parent 21ac188 commit b20632c
Show file tree
Hide file tree
Showing 5 changed files with 87 additions and 106 deletions.
61 changes: 0 additions & 61 deletions docs/telemetry.md

This file was deleted.

32 changes: 32 additions & 0 deletions docs/telemetry.md.js
Original file line number Diff line number Diff line change
@@ -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.
`);
44 changes: 5 additions & 39 deletions src/telemetry.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,15 @@
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";
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<typeof randomUUID>;

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;
Expand Down Expand Up @@ -79,7 +45,7 @@ export class Telemetry {
private endpoint: URL;
private timeZoneOffset = new Date().getTimezoneOffset();
private readonly _pending = new Set<Promise<unknown>>();
private _config: Promise<Record<string, uuid>> | undefined;
private _config: Promise<Record<string, UUID>> | undefined;
private _ids: Promise<TelemetryIds> | undefined;
private _environment: Promise<TelemetryEnvironment> | undefined;

Expand Down Expand Up @@ -142,7 +108,7 @@ export class Telemetry {
process.on(name, signaled);
}

private async getPersistentId(name: string, generator = randomUUID): Promise<uuid | null> {
private async getPersistentId(name: string, generator = randomUUID): Promise<UUID | null> {
const {readFile, writeFile} = this.effects;
const file = join(os.homedir(), ".observablehq");
if (!this._config) {
Expand Down Expand Up @@ -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(
Expand Down
44 changes: 44 additions & 0 deletions src/telemetryData.d.ts
Original file line number Diff line number Diff line change
@@ -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}`};
12 changes: 6 additions & 6 deletions test/telemetry-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,22 @@ 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();
});

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);
});
Expand All @@ -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]");
Expand All @@ -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);
Expand All @@ -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);
Expand Down

0 comments on commit b20632c

Please sign in to comment.