-
Notifications
You must be signed in to change notification settings - Fork 140
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
Telemetry #408
Merged
Merged
Telemetry #408
Changes from 16 commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
0bfde1b
Record anonymous usage telemetry
visnup fd73dcb
Stricter types
visnup 990f970
Better persistence
visnup 38031fe
Detect CI and Docker environments
visnup 4b7d003
Don't fail tests
visnup 2451e14
Configurable origin
visnup 8cc2ad8
Fix lint
visnup be683fb
Better time information
visnup 486dac7
Add isWSL heuristic
visnup fb9a1e3
Some tests
visnup 88d439b
Show a banner on first run
visnup d34e876
Test debug disables telemetry too
visnup 1a8b64c
Merge branch 'main' into visnup/telemetry
visnup 8e98080
More testable
visnup 4f69094
Merge branch 'main' into visnup/telemetry
visnup a0f8e84
Ironically, disable telemetry during tests
visnup c84f78d
Merge branch 'main' into visnup/telemetry
visnup 490f28f
Base telemetry origin on ui origin
visnup 78cb0ea
Some documentation of what we're collecting
visnup 5b63e88
Manage our own singleton-ness
visnup cddf7c2
Initial telemetry documentation
visnup aea94ed
Merge branch 'main' into visnup/telemetry
visnup File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,177 @@ | ||
import {exec} from "node:child_process"; | ||
import {createHash, randomUUID} from "node:crypto"; | ||
import {readFile, writeFile} from "node:fs/promises"; | ||
import {join} from "node:path"; | ||
import os from "os"; | ||
import type {Logger} from "./logger.js"; | ||
import {cyan, magenta} from "./tty.js"; | ||
|
||
type uuid = ReturnType<typeof randomUUID>; | ||
type TelemetryIds = { | ||
device: uuid; | ||
project: string; | ||
session: uuid; | ||
}; | ||
type TelemetryEnvironment = { | ||
version: string; | ||
systemPlatform: string; | ||
systemRelease: string; | ||
systemArchitecture: string; | ||
cpuCount: number; | ||
cpuModel: string | null; | ||
cpuSpeed: number | null; | ||
memoryInMb: number; | ||
}; | ||
type TelemetryTime = { | ||
now: number; | ||
timeOrigin: number; | ||
timeZoneOffset: number; | ||
}; | ||
type TelemetryData = { | ||
event: "build" | "deploy" | "preview"; | ||
step: "start" | "finish"; | ||
[key: string]: unknown; | ||
}; | ||
|
||
let _config: Record<string, uuid> | undefined; | ||
async function getPersistentId(name: string, generator = randomUUID) { | ||
const file = join(os.homedir(), ".observablehq"); | ||
if (!_config) { | ||
try { | ||
_config = JSON.parse(await readFile(file, "utf8")); | ||
} catch (e) { | ||
// fall through | ||
} | ||
_config ??= {}; | ||
} | ||
if (!_config[name]) { | ||
_config[name] = generator(); | ||
await writeFile(file, JSON.stringify(_config, null, 2)); | ||
} | ||
return _config[name]; | ||
} | ||
|
||
type TelemetryEffects = { | ||
env: NodeJS.ProcessEnv; | ||
logger: Logger; | ||
getPersistentId: typeof getPersistentId; | ||
}; | ||
const defaultEffects: TelemetryEffects = { | ||
env: process.env, | ||
logger: console, | ||
getPersistentId | ||
}; | ||
|
||
export class Telemetry { | ||
private disabled: boolean; | ||
private debug: boolean; | ||
private origin: string; | ||
private logger: Logger; | ||
private getPersistentId: typeof getPersistentId; | ||
private timeZoneOffset = new Date().getTimezoneOffset(); | ||
private readonly pending = new Set<Promise<any>>(); | ||
private _ids: Promise<TelemetryIds> | undefined; | ||
private _environment: Promise<TelemetryEnvironment> | undefined; | ||
|
||
constructor(effects = defaultEffects) { | ||
this.disabled = !!effects.env.OBSERVABLE_TELEMETRY_DISABLE; | ||
this.debug = !!effects.env.OBSERVABLE_TELEMETRY_DEBUG; | ||
this.origin = effects.env.OBSERVABLE_TELEMETRY_ORIGIN || "https://events.observablehq.com"; | ||
visnup marked this conversation as resolved.
Show resolved
Hide resolved
|
||
this.logger = effects.logger; | ||
this.getPersistentId = effects.getPersistentId; | ||
} | ||
|
||
async record(data: TelemetryData) { | ||
if (this.disabled) return; | ||
const task = (async () => | ||
this.send({ | ||
ids: await this.ids, | ||
environment: await this.environment, | ||
time: {now: performance.now(), timeOrigin: performance.timeOrigin, timeZoneOffset: this.timeZoneOffset}, | ||
mythmon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
data | ||
}) | ||
.catch(() => {}) | ||
.finally(() => { | ||
this.pending.delete(task); | ||
}))(); | ||
this.pending.add(task); | ||
} | ||
|
||
flush() { | ||
return Promise.all(this.pending); | ||
} | ||
|
||
private async getProjectId() { | ||
const remote: string | null = await new Promise((resolve) => { | ||
exec("git config --local --get remote.origin.url", (error, stdout) => resolve(error ? null : stdout.trim())); | ||
}); | ||
const hash = createHash("sha256"); | ||
hash.update(await this.getPersistentId("cli_telemetry_salt")); | ||
hash.update(remote || process.env.REPOSITORY_URL || process.cwd()); | ||
return hash.digest("base64"); | ||
} | ||
|
||
private get ids() { | ||
return (this._ids ??= Promise.all([this.getPersistentId("cli_telemetry_device"), this.getProjectId()]).then( | ||
([device, project]) => ({ | ||
device, | ||
project, | ||
session: randomUUID() | ||
}) | ||
)); | ||
} | ||
|
||
private get environment() { | ||
return (this._environment ??= Promise.all([ | ||
visnup marked this conversation as resolved.
Show resolved
Hide resolved
|
||
import("../package.json"), | ||
import("ci-info"), | ||
import("is-docker"), | ||
import("is-wsl") | ||
]).then(([{default: pkg}, ci, {default: isDocker}, {default: isWSL}]) => { | ||
const cpus = os.cpus() || []; | ||
return { | ||
version: pkg.version, | ||
systemPlatform: os.platform(), | ||
systemRelease: os.release(), | ||
systemArchitecture: os.arch(), | ||
cpuCount: cpus.length, | ||
cpuModel: cpus.length ? cpus[0].model : null, | ||
cpuSpeed: cpus.length ? cpus[0].speed : null, | ||
memoryInMb: Math.trunc(os.totalmem() / Math.pow(1024, 2)), | ||
isCI: ci.name || ci.isCI, | ||
isDocker: isDocker(), | ||
isWSL | ||
}; | ||
})); | ||
} | ||
|
||
private async needsBanner() { | ||
let called; | ||
await this.getPersistentId("cli_telemetry_banner", () => (called = randomUUID())); | ||
return !!called; | ||
} | ||
|
||
private async send(data: { | ||
ids: TelemetryIds; | ||
environment: TelemetryEnvironment; | ||
time: TelemetryTime; | ||
data: TelemetryData; | ||
}): Promise<void> { | ||
if (await this.needsBanner()) { | ||
this.logger.error( | ||
visnup marked this conversation as resolved.
Show resolved
Hide resolved
|
||
`${magenta("Attention")}: Observable CLI collect anonymous telemetry data.\nSee ${cyan( | ||
"https://observablehq.com/cli-telemetry" | ||
)} for details and how to opt-out.` | ||
); | ||
} | ||
if (this.debug) { | ||
this.logger.error("[telemetry]", data); | ||
return; | ||
} | ||
await fetch(`${this.origin}/cli`, { | ||
method: "POST", | ||
body: JSON.stringify(data), | ||
headers: {"content-type": "application/json"} | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import assert from "assert"; | ||
import {randomUUID} from "crypto"; | ||
import {MockAgent, getGlobalDispatcher, setGlobalDispatcher} from "undici"; | ||
import {Telemetry} from "../src/telemetry.js"; | ||
import {MockLogger} from "./mocks/logger.js"; | ||
|
||
describe("telemetry", () => { | ||
const globalDispatcher = getGlobalDispatcher(); | ||
let agent; | ||
|
||
beforeEach(() => { | ||
agent = new MockAgent(); | ||
agent.disableNetConnect(); | ||
agent.get("https://events.observablehq.com").intercept({path: "/cli", method: "POST"}).reply(204); | ||
setGlobalDispatcher(agent); | ||
}); | ||
afterEach(() => { | ||
setGlobalDispatcher(globalDispatcher); | ||
}); | ||
|
||
const noopEffects = {env: {}, logger: new MockLogger(), getPersistentId: async () => randomUUID()}; | ||
|
||
it("sends data", async () => { | ||
const telemetry = new Telemetry(noopEffects); | ||
telemetry.record({event: "build", step: "start", test: true}); | ||
await telemetry.flush(); | ||
agent.assertNoPendingInterceptors(); | ||
}); | ||
|
||
it("shows a banner", async () => { | ||
const logger = new MockLogger(); | ||
const telemetry = new Telemetry({ | ||
...noopEffects, | ||
logger, | ||
getPersistentId: async (name, generator = randomUUID) => generator() | ||
}); | ||
telemetry.record({event: "build", step: "start", test: true}); | ||
await telemetry.flush(); | ||
logger.assertExactErrors([/Attention.*telemetry.*https:\/\/observablehq.com/s]); | ||
}); | ||
|
||
it("can be disabled", async () => { | ||
const telemetry = new Telemetry({...noopEffects, env: {OBSERVABLE_TELEMETRY_DISABLE: "1"}}); | ||
telemetry.record({event: "build", step: "start", test: true}); | ||
await telemetry.flush(); | ||
assert.equal(agent.pendingInterceptors().length, 1); | ||
}); | ||
|
||
it("debug prints data and disables", async () => { | ||
const logger = new MockLogger(); | ||
const telemetry = new Telemetry({...noopEffects, env: {OBSERVABLE_TELEMETRY_DEBUG: "1"}, logger}); | ||
telemetry.record({event: "build", step: "start", test: true}); | ||
await telemetry.flush(); | ||
assert.equal(logger.errorLines.length, 1); | ||
assert.equal(logger.errorLines[0][0], "[telemetry]"); | ||
assert.equal(agent.pendingInterceptors().length, 1); | ||
}); | ||
|
||
it("silent on error", async () => { | ||
const logger = new MockLogger(); | ||
agent.get("https://localhost").intercept({path: "/cli", method: "POST"}).replyWithError(new Error("silent")); | ||
const telemetry = new Telemetry({...noopEffects, env: {OBSERVABLE_TELEMETRY_ORIGIN: "https://localhost"}, logger}); | ||
telemetry.record({event: "build", step: "start", test: true}); | ||
await telemetry.flush(); | ||
assert.equal(logger.errorLines.length, 0); | ||
assert.equal(agent.pendingInterceptors().length, 1); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -985,6 +985,11 @@ [email protected]: | |
optionalDependencies: | ||
fsevents "~2.3.2" | ||
|
||
ci-info@^4.0.0: | ||
version "4.0.0" | ||
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.0.0.tgz#65466f8b280fc019b9f50a5388115d17a63a44f2" | ||
integrity sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg== | ||
|
||
cliui@^7.0.2: | ||
version "7.0.4" | ||
resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" | ||
|
@@ -2298,6 +2303,13 @@ is-wsl@^2.2.0: | |
dependencies: | ||
is-docker "^2.0.0" | ||
|
||
is-wsl@^3.1.0: | ||
version "3.1.0" | ||
resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-3.1.0.tgz#e1c657e39c10090afcbedec61720f6b924c3cbd2" | ||
integrity sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw== | ||
dependencies: | ||
is-inside-container "^1.0.0" | ||
|
||
isarray@^2.0.5: | ||
version "2.0.5" | ||
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" | ||
|
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I bet this kind of "start/end" telemetry is going to often be interesting for us. Is it possible to link ends to their starts at all? Maybe
telemetry.record
could return a message ID that we could note in the end event?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the session id will be stable across tied events.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So the workflow on the analytic side would be to find a start event, and then look for end events with the same session id? Will that be annoying if there is an event that happens multiple times in a telemetry session?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh, do you mean like in the case of maybe two builds happening concurrently and the events get interleaved?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I mean if we have a start event for something more granular, like one per page. Is that just not what this is for? Having average time-per-page and number-of-pages metrics would be useful.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah, we could do something like that. so just easier rolled-up timings of blocks of code, right? maybe a premature idea would be to provide a
telemetry.measure({event: "something"}, () => { /* block */ })
. I guess I'm still not sure exactly what we'll want to measure so am willing to iterate a bunch and probably throw out old data as we do.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I could return an identifier (a counter probably that just increases with every call to
record
) if people want to reference these in other events:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The block version is familiar, but it feels heavy handed. I'd really like language support for something like destructors, which makes this very nice, but we don't have those.
I think the
ts
version makes sense, and I'd be happy with that.Another idea I had was to have the return value of
record
be a function or object with methods so you could do something likeThat's probably overkill though. Having
.record
always give back an auto incrementing ID sounds like a good approach.