From ba6e44ec0547974c311b91df6650e54b83d4b5ec Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 7 Nov 2026 04:19:03 +0000 Subject: [PATCH 1/2] internal-for-testing: add crash_handler.isAnalyticsEnabled() Debug and ASAN builds return from is_reporting_enabled() before the analytics check, so a test cannot observe the bunfig telemetry setting through a crash. The binding returns bun_analytics::is_enabled(). --- src/runtime/api/crash_handler_jsc.bind.ts | 1 + src/runtime/api/crash_handler_jsc.rs | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/runtime/api/crash_handler_jsc.bind.ts b/src/runtime/api/crash_handler_jsc.bind.ts index c8de394f61ba..fadd9a6a241a 100644 --- a/src/runtime/api/crash_handler_jsc.bind.ts +++ b/src/runtime/api/crash_handler_jsc.bind.ts @@ -13,6 +13,7 @@ export const FeatureData = b.dictionary({ export const crashHandler = b.namespace({ getMachOImageZeroOffset: { returns: b.f64.optional, length: 1 }, getFeatureData: { returns: FeatureData, length: 1 }, + isAnalyticsEnabled: { returns: b.bool }, segfault: { length: 1 }, segfaultHoldingThreadSuspendLock: { length: 1 }, segfaultInDll: { length: 1 }, diff --git a/src/runtime/api/crash_handler_jsc.rs b/src/runtime/api/crash_handler_jsc.rs index 31e20bd38395..c25558f4cdc8 100644 --- a/src/runtime/api/crash_handler_jsc.rs +++ b/src/runtime/api/crash_handler_jsc.rs @@ -259,3 +259,7 @@ pub(crate) fn get_feature_data() -> FeatureData { generated_at: bun_core::time::milli_timestamp().max(0) as u64, } } + +pub(crate) fn is_analytics_enabled() -> bool { + analytics::is_enabled() +} From ca35eace01623980f5c3f7421e24ed61ccb3ea8a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:17:30 +0000 Subject: [PATCH 2/2] bunfig: apply `telemetry = false` in every command that reads the file The `telemetry` key was applied only for `bun run` and `bun `. `bun test`, `bun build`, `bun compile`, the package manager commands, `bun lint`, `bun fmt` and bun as `node --config` parsed the file and dropped the key, so a crash still uploaded a report. Parser::parse now reads the key on every parse but the `[install]`-only one, before anything in the file can fail. A value that is not `true` turns the upload off. The type check stays where it was, for `bun run` and `bun `, so no command gets a new error. bun_analytics keeps one set-only flag in place of the tri-state. A `telemetry` that is not `true`, DO_NOT_TRACK or HYPERFINE_RANDOMIZED_ENVIRONMENT_OFFSET each turn the upload off, and `telemetry = true` turns nothing back on. `bun lint` and `bun fmt` read an ESLint or Prettier config in a second process, which reads no bunfig.toml. They pass the opt-out to it as DO_NOT_TRACK=1. --- docs/runtime/bunfig.mdx | 17 +- src/analytics/lib.rs | 67 ++------ src/bunfig/arguments.rs | 2 +- src/bunfig/bunfig.rs | 16 +- src/runtime/cli/lint_command.rs | 3 +- test/config/bunfig/telemetry.test.ts | 225 +++++++++++++++++++++++++++ 6 files changed, 265 insertions(+), 65 deletions(-) create mode 100644 test/config/bunfig/telemetry.test.ts diff --git a/docs/runtime/bunfig.mdx b/docs/runtime/bunfig.mdx index be6f0336750f..2f487c6f48af 100644 --- a/docs/runtime/bunfig.mdx +++ b/docs/runtime/bunfig.mdx @@ -18,7 +18,7 @@ To configure Bun's package manager globally, you can also create a `.bunfig.toml When `XDG_CONFIG_HOME` is set to an absolute path, Bun reads the file there and not the one in `$HOME`. -Only package manager commands (`bun install`, `bun add`, `bun remove`, `bun update`, `bun pm`, `bunx`, and so on) read the global file. If Bun finds both a global and a local `bunfig`, it loads both; keys set in the local file override the same keys in the global file. CLI flags override `bunfig` settings where applicable. +Only package manager commands (`bun install`, `bun add`, `bun remove`, `bun update`, `bun pm`, `bunx`, and so on) read the global file. If Bun finds both a global and a local `bunfig`, it loads both; keys set in the local file override the same keys in the global file. The exception is [`telemetry`](#telemetry): `telemetry = false` in either file counts. CLI flags override `bunfig` settings where applicable. Like npm, Bun skips any of these files that it can't read. @@ -111,7 +111,7 @@ Bun supports the following loaders: ### `telemetry` -The `telemetry` field enables or disables analytics. By default, telemetry is enabled. This setting is equivalent to the `DO_NOT_TRACK` environment variable. +Set `telemetry = false` to turn off analytics. By default, telemetry is enabled. We do not currently collect telemetry; this setting only controls anonymous crash reports. We plan to collect information like which Bun APIs are used most or how long `bun build` takes. @@ -119,6 +119,19 @@ We do not currently collect telemetry; this setting only controls anonymous cras telemetry = false ``` +A command applies `telemetry = false` from the `bunfig.toml` files it reads, for its own process. `bun `, `bun run`, `bun test` and `bun build` read the one in the working directory, or the `--config` file. Package manager commands read the project's file and the global `.bunfig.toml` (see [Global vs. local](#global-vs-local)). A `bun` process that a script or a test starts reads its own `bunfig.toml`. + +The setting does not reach: + +- `bun `, `bun run`, `bun test` and `bun build` in another directory, such as a workspace package below the project root +- those same commands when the key is only in the global `.bunfig.toml` +- `bunx` and `bun exec` +- an executable built with `bun build --compile`, which reads `bunfig.toml` only from the directory it is started in + +For these, set [`DO_NOT_TRACK=1`](/runtime/environment-variables) in the environment that starts Bun, for example `DO_NOT_TRACK=1 bun test`. Bun does not read `DO_NOT_TRACK` from `.env` files. + +`telemetry = true` does not turn the uploads back on. It overrides neither `DO_NOT_TRACK=1` nor, for package manager commands, `telemetry = false` in the global `.bunfig.toml`. + ### `env` Configure automatic `.env` file loading. Bun loads `.env` files by default. To disable this behavior: diff --git a/src/analytics/lib.rs b/src/analytics/lib.rs index 3f2847eb86aa..b0064b7bbf7d 100644 --- a/src/analytics/lib.rs +++ b/src/analytics/lib.rs @@ -1,5 +1,5 @@ use core::fmt; -use core::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::OnceLock; use bun_core::env_var; @@ -11,64 +11,21 @@ use bun_core::slice_to_nul; // ────────────────────────────────────────────────────────────────────────── -/// Enables analytics. This is used by: -/// - crash_handler's `report` function to anonymously report crashes -/// -/// Since this field can be `Unknown`, it makes more sense to call `is_enabled` -/// instead of processing this field directly. -#[repr(u8)] -#[derive(Copy, Clone, PartialEq, Eq)] -pub enum TriState { - Yes = 0, - No = 1, - Unknown = 2, -} - -impl TriState { - #[inline] - const fn from_u8(v: u8) -> Self { - match v { - 0 => TriState::Yes, - 1 => TriState::No, - _ => TriState::Unknown, - } - } -} - -static ENABLED: AtomicU8 = AtomicU8::new(TriState::Unknown as u8); +static CONFIG_OPT_OUT: AtomicBool = AtomicBool::new(false); -fn enabled() -> TriState { - TriState::from_u8(ENABLED.load(Ordering::Relaxed)) -} -pub fn set_enabled(v: TriState) { - ENABLED.store(v as u8, Ordering::Relaxed); +/// For a `telemetry` in a bunfig that is not `true`. Nothing clears it: no config turns reporting back on. +#[inline] +pub fn opt_out_by_config() { + CONFIG_OPT_OUT.store(true, Ordering::Relaxed); } +/// Whether the crash handler may upload a report. Any one opt-out is enough to say no. pub fn is_enabled() -> bool { - match enabled() { - TriState::Yes => !env_var::DO_NOT_TRACK.get().unwrap_or(false), - TriState::No => false, - TriState::Unknown => { - let detected = 'detect: { - // `env_var::*.get()` returns `Option` even when a - // default exists; `DO_NOT_TRACK` has `default: false`, hence - // `.unwrap_or(false)`. - if env_var::DO_NOT_TRACK.get().unwrap_or(false) { - break 'detect TriState::No; - } - if env_var::HYPERFINE_RANDOMIZED_ENVIRONMENT_OFFSET - .get() - .is_some() - { - break 'detect TriState::No; - } - TriState::Yes - }; - set_enabled(detected); - debug_assert!(matches!(enabled(), TriState::Yes | TriState::No)); - enabled() == TriState::Yes - } - } + !CONFIG_OPT_OUT.load(Ordering::Relaxed) + && !env_var::DO_NOT_TRACK.get().unwrap_or(false) + && env_var::HYPERFINE_RANDOMIZED_ENVIRONMENT_OFFSET + .get() + .is_none() } // ────────────────────────────────────────────────────────────────────────── diff --git a/src/bunfig/arguments.rs b/src/bunfig/arguments.rs index c9f6eb37c6fe..7709ad8eca3e 100644 --- a/src/bunfig/arguments.rs +++ b/src/bunfig/arguments.rs @@ -389,7 +389,7 @@ pub enum Above { /// For `bun lint` and `bun fmt`, which have not found all that is for them in the working directory: has `tool` read the /// bunfig.toml of each directory above it, from the nearest, for as long as it wants the next. It is called with the file and -/// its path from the working directory. Nothing else in these files counts. +/// its path from the working directory. Nothing else in these files counts, but a `telemetry` that turns crash reports off. pub fn load_config_above_for_tool( cmd: CommandTag, ctx: Context<'_>, diff --git a/src/bunfig/bunfig.rs b/src/bunfig/bunfig.rs index 917d32363f19..d6a50915c6d0 100644 --- a/src/bunfig/bunfig.rs +++ b/src/bunfig/bunfig.rs @@ -405,6 +405,15 @@ impl<'a> Parser<'a, '_> { self.add_error(json.loc, b"bunfig expects an object { } at the root")?; } + // Read before anything in the file can fail: nothing but `true` leaves crash reports on. + let telemetry = match self.counts { + Counts::Everything | Counts::OnlyForTheTool => json.get(b"telemetry"), + Counts::OnlyInstall => None, + }; + if telemetry.is_some_and(|expr| expr.as_bool() != Some(true)) { + bun_analytics::opt_out_by_config(); + } + if self.counts == Counts::OnlyForTheTool { return self.read_for_tool(&json); } @@ -453,13 +462,8 @@ impl<'a> Parser<'a, '_> { self.load_preload(&expr)?; } - if let Some(expr) = json.get(b"telemetry") { + if let Some(expr) = telemetry { self.expect(&expr, ExprTag::EBoolean)?; - bun_analytics::set_enabled(if expr.as_bool().expect("infallible: type checked") { - bun_analytics::TriState::Yes - } else { - bun_analytics::TriState::No - }); } } diff --git a/src/runtime/cli/lint_command.rs b/src/runtime/cli/lint_command.rs index 0353937b351c..89c860fff838 100644 --- a/src/runtime/cli/lint_command.rs +++ b/src/runtime/cli/lint_command.rs @@ -158,7 +158,8 @@ fn run_script(script: &Script) -> Result, Vec> { Script::NOT_INHERITED .iter() .for_each(|name| variables.remove(name)); - true + // The program reads no bunfig.toml, so a `telemetry = false` of this process goes with it in the environment. + bun_analytics::is_enabled() || variables.put(b"DO_NOT_TRACK", b"1").is_ok() }; match run_to_end(argv, script.cwd, &adapt, &input)? { result if result.is_ok() => Ok(result.stdout), diff --git a/test/config/bunfig/telemetry.test.ts b/test/config/bunfig/telemetry.test.ts new file mode 100644 index 000000000000..c80e8d7403fb --- /dev/null +++ b/test/config/bunfig/telemetry.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isASAN, isDebug, isMacOS, isWindows, tempDir } from "harness"; +import { chmodSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { isHardened } from "../../aot/hardened"; +import { withoutCoreDump } from "../../aot/harness/without-core-dump"; + +// A `telemetry` in bunfig.toml that is not `true` stops the crash handler from uploading a report, in every command +// that reads the file. `telemetry = true` turns nothing back on. + +// What the machine that runs the tests has set must not decide the answer. +const env = { ...bunEnv, DO_NOT_TRACK: undefined, HYPERFINE_RANDOMIZED_ENVIRONMENT_OFFSET: undefined }; + +// Debug and ASAN builds never upload a report. A binding says what the crash handler would be told. +const says = `""`; +const told = ({ stdout, stderr }: Result) => //.exec(stdout + stderr)?.[1] ?? stderr; + +// `bun lint` runs a plugin in its own process. +const lintsWithPlugin = `[lint]\nplugins = ["./plugin.cjs"]\n[lint.rules]\n"says/nothing" = "warn"\n`; +const files = { + "telemetry.js": `import { crash_handler } from "bun:internal-for-testing";\nexport const telemetry = () => ${says};\n`, + "a.js": `import { telemetry } from "./telemetry.js";\nconsole.log(telemetry());\n`, + "a.test.js": `import { test } from "bun:test";\nimport { telemetry } from "./telemetry.js";\ntest("says it", () => console.log(telemetry()));\n`, + "build.js": `import { telemetry } from "./telemetry.js" with { type: "macro" };\nconsole.log(telemetry());\n`, + "plugin.cjs": `const { crash_handler } = require("bun:internal-for-testing");\nconsole.error(${says});\nmodule.exports = { meta: { name: "says" }, rules: { nothing: { create: () => ({}) } } };\n`, + "below/clean.js": `export const a = 1;\n`, + // `bun fmt` reads this file in a second process, which reads no bunfig.toml. + "prettier/prettier.config.js": `import { crash_handler } from "bun:internal-for-testing";\nthrow new Error(${says});\n`, + "prettier/clean.js": `export const a = 1;\n`, + "prettier/package.json": JSON.stringify({ name: "bunfig-telemetry-prettier", type: "module" }), + "fault.js": `import { read } from "bun:ffi";\nexport const crash = () => read.u8(8);\n`, + "crash.js": `import { crash } from "./fault.js";\ncrash();\n`, + "crash.test.js": `import { test } from "bun:test";\nimport { crash } from "./fault.js";\ntest("crashes", () => crash());\n`, + "crash-build.js": `import { crash } from "./fault.js" with { type: "macro" };\nconsole.log(crash());\n`, + "package.json": JSON.stringify({ name: "bunfig-telemetry" }), + // Stands first on PATH in place of the program that uploads a report, and uploads nothing. + "bin/curl": `#!/bin/sh\nprintf '%s\\n' "$*" >> "$CALLS"\necho "uploader ran" >&2\n`, +}; +const project = (telemetry?: string) => + tempDir( + "bunfig-telemetry", + telemetry === undefined + ? files + : { ...files, "bunfig.toml": telemetry + lintsWithPlugin, "prettier/bunfig.toml": telemetry }, + ); + +async function run(cwd: string, cmd: string[], vars: Record = {}) { + await using proc = Bun.spawn({ cmd, env: { ...env, ...vars }, cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} +type Result = Awaited>; + +// The tests below that read the binding run one at a time. Under a debug build each of them takes about a second, and +// run together they pass the default timeout. + +describe("`telemetry = false` turns the upload off", () => { + const commands: [name: string, command: string[], cwd?: string][] = [ + ["bun ", ["a.js"]], + ["bun run ", ["run", "a.js"]], + ["bun test", ["test", "a.test.js"]], + ["bun as node --config=bunfig.toml", ["--bun", "node", "--config=bunfig.toml", "a.js"]], + ["bun lint", ["lint", "a.js"]], + ["bun lint, in a directory below the file", ["lint", "clean.js"], "below"], + ["bun fmt, in the process that reads prettier.config.js", ["fmt", "clean.js"], "prettier"], + ]; + test.each(commands)("for %s", async (_, command, cwd = "") => { + using dir = project("telemetry = false\n"); + expect(told(await run(join(String(dir), cwd), [bunExe(), ...command]))).toBe("false"); + }); + + // A macro reaches the binding only in a debug build. The crash handler tests below cover the other builds. + test.skipIf(!isDebug)("for bun build", async () => { + using dir = project("telemetry = false\n"); + expect(told(await run(String(dir), [bunExe(), "build", "build.js"]))).toBe("false"); + }); +}); + +describe("any one opt-out is enough, and `telemetry = true` is none", () => { + const [asked, hyperfine] = ["telemetry = true\n", { HYPERFINE_RANDOMIZED_ENVIRONMENT_OFFSET: "1" }]; + const settings: [name: string, bunfig: string | undefined, vars: Record, telemetry: string][] = [ + ["nothing set", undefined, {}, "true"], + ["telemetry = true", asked, {}, "true"], + ["DO_NOT_TRACK=1", undefined, { DO_NOT_TRACK: "1" }, "false"], + ["telemetry = true and DO_NOT_TRACK=1", asked, { DO_NOT_TRACK: "1" }, "false"], + ["HYPERFINE_RANDOMIZED_ENVIRONMENT_OFFSET=1", undefined, hyperfine, "false"], + ["telemetry = true and HYPERFINE_RANDOMIZED_ENVIRONMENT_OFFSET=1", asked, hyperfine, "false"], + ]; + test.each(settings)("%s", async (_, bunfig, vars, telemetry) => { + using dir = project(bunfig); + const result = await run(String(dir), [bunExe(), "a.js"], vars); + expect({ telemetry: told(result), exitCode: result.exitCode }).toEqual({ telemetry, exitCode: 0 }); + }); +}); + +describe("a `telemetry` that is not a boolean", () => { + const notBooleans = ['telemetry = "no"\n', "[telemetry]\nenabled = false\n"]; + + // Only `bun ` and `bun run` check the type of the key. + test.each(notBooleans)("turns the upload off for bun test, and is no error there: %j", async bunfig => { + using dir = project(bunfig); + const result = await run(String(dir), [bunExe(), "test", "a.test.js"]); + expect({ telemetry: told(result), exitCode: result.exitCode }).toEqual({ telemetry: "false", exitCode: 0 }); + expect(result.stderr).not.toContain("expected boolean"); + }); + + test("turns the upload off for bun lint in a directory below the file, and is no error there", async () => { + using dir = project(notBooleans[0]); + const result = await run(join(String(dir), "below"), [bunExe(), "lint", "clean.js"]); + expect({ telemetry: told(result), exitCode: result.exitCode }).toEqual({ telemetry: "false", exitCode: 0 }); + expect(result.stderr).not.toContain("expected boolean"); + }); +}); + +// `bun run ` prints what is wrong with a bunfig.toml and runs the file. Whether it runs the file or not, it +// does not do so with the upload on. +test.each(['telemetry = "no"\n', "logLevel = 3\ntelemetry = false\n"])( + "bun run does not go on with the upload on after a bunfig.toml that fails to load: %j", + async bunfig => { + using dir = project(bunfig); + const result = await run(String(dir), [bunExe(), "run", "a.js"]); + expect(result.stderr).toContain("error: expected"); + expect(told(result)).not.toBe("true"); + }, +); + +// Only a build that uploads reports without being asked gets as far as the setting: a canary build, and a release for +// macOS. On Windows the program that uploads is not found through PATH. +const isCanary = () => + Bun.spawnSync({ cmd: [bunExe(), "--revision"], env }) + .stdout.toString() + .includes("-canary"); +const uploadsReports = !isWindows && !isDebug && !isASAN && !isHardened && (isMacOS || isCanary()); + +describe.concurrent.skipIf(!uploadsReports)("the crash handler", () => { + const asksNobody = { BUN_CRASH_REPORT_URL: undefined, BUN_ENABLE_CRASH_REPORTING: undefined }; + + // The program that uploads holds the pipe for errors too, so the pipe ends when that program has ended. `ran` is + // what it wrote to that pipe: it would be missing if the crash handler ever closed the pipe first. + const outcome = (stderr: string, calls: string) => ({ + crashed: stderr.includes("bun.report"), + ran: stderr.includes("uploader ran"), + upload: existsSync(calls) ? readFileSync(calls, "utf8") : "", + }); + const uploaded = { crashed: true, ran: true, upload: expect.stringContaining("bun.report") }; + const nothing = { crashed: true, ran: false, upload: "" }; + + async function crashOf(command: string[], bunfig?: string) { + using dir = project(bunfig); + const [bin, calls] = ["bin", "calls"].map(name => join(String(dir), name)); + chmodSync(join(bin, "curl"), 0o755); + const vars = { ...asksNobody, PATH: bin, CALLS: calls }; + return outcome((await run(String(dir), withoutCoreDump([bunExe(), ...command]), vars)).stderr, calls); + } + + const commands: [name: string, command: string[]][] = [ + ["bun ", ["crash.js"]], + ["bun test", ["test", "crash.test.js"]], + ["bun build", ["build", "crash-build.js"]], + ]; + test.each(commands)("uploads a report for %s when nothing turns that off", async (_, command) => { + expect(await crashOf(command)).toEqual(uploaded); + }); + test.each(commands)("uploads nothing for %s with `telemetry = false`", async (_, command) => { + expect(await crashOf(command, "telemetry = false\n")).toEqual(nothing); + }); + + // A package manager command runs no JavaScript in its own process. Here it waits for a script of the package, and a + // signal ends it. + async function crashOfInstall(bunfig?: string, globalBunfig = "") { + using dir = tempDir("bunfig-telemetry-install", { + "package.json": JSON.stringify({ + name: "bunfig-telemetry", + scripts: { postinstall: `${JSON.stringify(bunExe())} waits.js` }, + }), + // The file appears with the number in it. + "waits.js": `const { writeFileSync, renameSync } = require("fs");\nwriteFileSync("waiting.tmp", String(process.pid));\nrenameSync("waiting.tmp", "waiting");\nsetInterval(() => {}, 1 << 30);\n`, + "bin/curl": files["bin/curl"], + "config/.bunfig.toml": globalBunfig, + ...(bunfig === undefined ? {} : { "bunfig.toml": bunfig }), + }); + const [bin, calls, waiting] = ["bin", "calls", "waiting"].map(name => join(String(dir), name)); + chmodSync(join(bin, "curl"), 0o755); + await using proc = Bun.spawn({ + cmd: withoutCoreDump([bunExe(), "install"]), + env: { + ...env, + ...asksNobody, + PATH: bin + ":" + process.env.PATH, + CALLS: calls, + XDG_CONFIG_HOME: join(String(dir), "config"), + BUN_INSTALL_CACHE_DIR: join(String(dir), "cache"), + }, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + const stderr = proc.stderr.text(); + for (const deadline = Date.now() + 30_000; !existsSync(waiting) && Date.now() < deadline;) await Bun.sleep(5); + const script = Number(readFileSync(waiting, "utf8")); + // Zero and what is below it name more than one process. + expect(script).toBeGreaterThan(0); + proc.kill("SIGSEGV"); + await proc.exited; + // Linux ends the script with the command that started it. Elsewhere the script is still there, with the pipe. + try { + process.kill(script, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + return outcome(await stderr, calls); + } + + test("uploads a report for bun install when nothing turns that off", async () => { + expect(await crashOfInstall()).toEqual(uploaded); + }); + + test("uploads nothing for bun install with `telemetry = false`", async () => { + expect(await crashOfInstall("telemetry = false\n")).toEqual(nothing); + }); + + test("uploads nothing for bun install with `telemetry = false` in the global file and `true` in the project", async () => { + expect(await crashOfInstall("telemetry = true\n", "telemetry = false\n")).toEqual(nothing); + }); +});