Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

more precise telemetry types #1765

Merged
merged 1 commit into from
Oct 17, 2024
Merged
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
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});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reminded me why I annotated these as test: true: in case these events ran in CI and made it into our production events, I wanted an easy way to filter them out. Unsure if that's a good enough reason to keep it this way or not or simply try to firewall off any chance of them showing up in our actual data.

I did just double check and we have no recorded events with test: true in them.

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