diff --git a/.changeset/perfect-cougars-lie.md b/.changeset/perfect-cougars-lie.md new file mode 100644 index 000000000000..00c196585bf5 --- /dev/null +++ b/.changeset/perfect-cougars-lie.md @@ -0,0 +1,9 @@ +--- +"wrangler": minor +--- + +Add anonymous telemetry to Wrangler commands + +For new users, Cloudflare will collect anonymous usage telemetry to guide and improve Wrangler's development. If you have already opted out of Wrangler's existing telemetry, this setting will still be respected. + +See our [data policy](https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md) for more details on what we collect and how to opt out if you wish. diff --git a/.github/workflows/create-pullrequest-prerelease.yml b/.github/workflows/create-pullrequest-prerelease.yml index acdb65fe2a2c..9d6d378bf7e6 100644 --- a/.github/workflows/create-pullrequest-prerelease.yml +++ b/.github/workflows/create-pullrequest-prerelease.yml @@ -57,6 +57,8 @@ jobs: run: node .github/prereleases/2-build-pack-upload.mjs env: NODE_ENV: "production" + # this is the "test/staging" key for sparrow analytics + SPARROW_SOURCE_KEY: "5adf183f94b3436ba78d67f506965998" ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }} ALGOLIA_PUBLIC_KEY: ${{ secrets.ALGOLIA_PUBLIC_KEY }} SENTRY_DSN: "https://9edbb8417b284aa2bbead9b4c318918b@sentry10.cfdata.org/583" diff --git a/packages/create-cloudflare/src/helpers/__tests__/command.test.ts b/packages/create-cloudflare/src/helpers/__tests__/command.test.ts index cfacfc987a34..4c8350a3448f 100644 --- a/packages/create-cloudflare/src/helpers/__tests__/command.test.ts +++ b/packages/create-cloudflare/src/helpers/__tests__/command.test.ts @@ -1,5 +1,6 @@ import { existsSync } from "fs"; import { spawn } from "cross-spawn"; +import { readMetricsConfig } from "helpers/metrics-config"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import whichPMRuns from "which-pm-runs"; import { quoteShellArgs, runCommand } from "../command"; @@ -13,6 +14,7 @@ let spawnStderr: string | undefined = undefined; vi.mock("cross-spawn"); vi.mock("fs"); vi.mock("which-pm-runs"); +vi.mock("helpers/metrics-config"); describe("Command Helpers", () => { afterEach(() => { @@ -55,6 +57,62 @@ describe("Command Helpers", () => { }); }); + describe("respect telemetry permissions when running wrangler", () => { + test("runCommand has WRANGLER_SEND_METRICS=false if its a wrangler command and c3 telemetry is disabled", async () => { + vi.mocked(readMetricsConfig).mockReturnValue({ + c3permission: { + enabled: false, + date: new Date(2000), + }, + }); + await runCommand(["npx", "wrangler"]); + + expect(spawn).toHaveBeenCalledWith( + "npx", + ["wrangler"], + expect.objectContaining({ + env: expect.objectContaining({ WRANGLER_SEND_METRICS: "false" }), + }), + ); + }); + + test("runCommand doesn't have WRANGLER_SEND_METRICS=false if its a wrangler command and c3 telemetry is enabled", async () => { + vi.mocked(readMetricsConfig).mockReturnValue({ + c3permission: { + enabled: true, + date: new Date(2000), + }, + }); + await runCommand(["npx", "wrangler"]); + + expect(spawn).toHaveBeenCalledWith( + "npx", + ["wrangler"], + expect.objectContaining({ + env: expect.not.objectContaining({ WRANGLER_SEND_METRICS: "false" }), + }), + ); + }); + + test("runCommand doesn't have WRANGLER_SEND_METRICS=false if not a wrangler command", async () => { + vi.mocked(readMetricsConfig).mockReturnValue({ + c3permission: { + enabled: false, + date: new Date(2000), + }, + }); + await runCommand(["ls", "-l"]); + + expect(spawn).toHaveBeenCalledWith( + "ls", + ["-l"], + expect.objectContaining({ + env: expect.not.objectContaining({ WRANGLER_SEND_METRICS: "false" }), + }), + ); + }); + }); + describe("quoteShellArgs", () => { test.runIf(process.platform !== "win32")("mac", async () => { expect(quoteShellArgs([`pages:dev`])).toEqual("pages:dev"); diff --git a/packages/create-cloudflare/src/helpers/command.ts b/packages/create-cloudflare/src/helpers/command.ts index 51547c2ad462..0f0b103942a5 100644 --- a/packages/create-cloudflare/src/helpers/command.ts +++ b/packages/create-cloudflare/src/helpers/command.ts @@ -2,6 +2,7 @@ import { stripAnsi } from "@cloudflare/cli"; import { CancelError } from "@cloudflare/cli/error"; import { isInteractive, spinner } from "@cloudflare/cli/interactive"; import { spawn } from "cross-spawn"; +import { readMetricsConfig } from "./metrics-config"; /** * Command is a string array, like ['git', 'commit', '-m', '"Initial commit"'] @@ -52,6 +53,15 @@ export const runCommand = async ( doneText: opts.doneText, promise() { const [executable, ...args] = command; + // Don't send metrics data on any wrangler commands if telemetry is disabled in C3 + // If telemetry is disabled separately for wrangler, wrangler will handle that + if (args[0] === "wrangler") { + const metrics = readMetricsConfig(); + if (metrics.c3permission?.enabled === false) { + opts.env ??= {}; + opts.env["WRANGLER_SEND_METRICS"] = "false"; + } + } const abortController = new AbortController(); const cmd = spawn(executable, [...args], { // TODO: ideally inherit stderr, but npm install uses this for warnings diff --git a/packages/wrangler/scripts/bundle.ts b/packages/wrangler/scripts/bundle.ts index 9fbe71d94700..4e23e3072ab3 100644 --- a/packages/wrangler/scripts/bundle.ts +++ b/packages/wrangler/scripts/bundle.ts @@ -44,9 +44,9 @@ async function buildMain(flags: BuildFlags = {}) { __RELATIVE_PACKAGE_PATH__, "import.meta.url": "import_meta_url", "process.env.NODE_ENV": `'${process.env.NODE_ENV || "production"}'`, - ...(process.env.SPARROW_SOURCE_KEY - ? { SPARROW_SOURCE_KEY: `"${process.env.SPARROW_SOURCE_KEY}"` } - : {}), + "process.env.SPARROW_SOURCE_KEY": JSON.stringify( + process.env.SPARROW_SOURCE_KEY ?? "" + ), ...(process.env.ALGOLIA_APP_ID ? { ALGOLIA_APP_ID: `"${process.env.ALGOLIA_APP_ID}"` } : {}), diff --git a/packages/wrangler/src/__tests__/init.test.ts b/packages/wrangler/src/__tests__/init.test.ts index ee015b75ed75..6f773d948070 100644 --- a/packages/wrangler/src/__tests__/init.test.ts +++ b/packages/wrangler/src/__tests__/init.test.ts @@ -9,6 +9,7 @@ import { File, FormData } from "undici"; import { vi } from "vitest"; import { version as wranglerVersion } from "../../package.json"; import { downloadWorker } from "../init"; +import { writeMetricsConfig } from "../metrics/metrics-config"; import { getPackageManager } from "../package-manager"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; @@ -149,6 +150,27 @@ describe("init", () => { ); }); }); + + test("if telemetry is disabled in wrangler, then disable for c3 too", async () => { + writeMetricsConfig({ + permission: { + enabled: false, + date: new Date(2024, 11, 11), + }, + }); + await runWrangler("init"); + + expect(execa).toHaveBeenCalledWith( + "mockpm", + ["create", "cloudflare@^2.5.0"], + { + env: { + CREATE_CLOUDFLARE_TELEMETRY_DISABLED: "1", + }, + stdio: "inherit", + } + ); + }); }); describe("deprecated behavior is retained with --no-delegate-c3", () => { diff --git a/packages/wrangler/src/__tests__/metrics.test.ts b/packages/wrangler/src/__tests__/metrics.test.ts index 4da693aaf533..5bf1c037e43b 100644 --- a/packages/wrangler/src/__tests__/metrics.test.ts +++ b/packages/wrangler/src/__tests__/metrics.test.ts @@ -1,80 +1,98 @@ -import { mkdirSync } from "node:fs"; import { http, HttpResponse } from "msw"; import { vi } from "vitest"; -import { version as wranglerVersion } from "../../package.json"; -import { purgeConfigCaches, saveToConfigCache } from "../config-cache"; import { CI } from "../is-ci"; import { logger } from "../logger"; -import { getMetricsConfig, getMetricsDispatcher } from "../metrics"; +import { sendMetricsEvent } from "../metrics"; import { - CURRENT_METRICS_DATE, + getNodeVersion, + getOS, + getOSVersion, + getPlatform, + getWranglerVersion, +} from "../metrics/helpers"; +import { + getMetricsConfig, readMetricsConfig, - USER_ID_CACHE_PATH, writeMetricsConfig, } from "../metrics/metrics-config"; -import { writeAuthConfigFile } from "../user"; +import { + getMetricsDispatcher, + redactArgValues, +} from "../metrics/metrics-dispatcher"; +import { sniffUserAgent } from "../package-manager"; import { mockConsoleMethods } from "./helpers/mock-console"; -import { clearDialogs, mockConfirm } from "./helpers/mock-dialogs"; import { useMockIsTTY } from "./helpers/mock-istty"; -import { msw, mswSuccessOauthHandlers } from "./helpers/msw"; +import { msw } from "./helpers/msw"; import { runInTempDir } from "./helpers/run-in-tmp"; +import { runWrangler } from "./helpers/run-wrangler"; +import { writeWranglerConfig } from "./helpers/write-wrangler-config"; import type { MockInstance } from "vitest"; -declare const global: { SPARROW_SOURCE_KEY: string | undefined }; +vi.mock("../metrics/helpers"); vi.unmock("../metrics/metrics-config"); +vi.mock("../metrics/send-event"); +vi.mock("../package-manager"); + +// eslint-disable-next-line @typescript-eslint/no-namespace +declare module globalThis { + let ALGOLIA_APP_ID: string | undefined; + let ALGOLIA_PUBLIC_KEY: string | undefined; +} describe("metrics", () => { - const ORIGINAL_SPARROW_SOURCE_KEY = global.SPARROW_SOURCE_KEY; + let isCISpy: MockInstance; const std = mockConsoleMethods(); + const { setIsTTY } = useMockIsTTY(); runInTempDir(); beforeEach(async () => { - global.SPARROW_SOURCE_KEY = "MOCK_KEY"; + isCISpy = vi.spyOn(CI, "isCI").mockReturnValue(false); + setIsTTY(true); + vi.stubEnv("SPARROW_SOURCE_KEY", "MOCK_KEY"); logger.loggerLevel = "debug"; - // Create a node_modules directory to store config-cache files - mkdirSync("node_modules"); }); + afterEach(() => { - global.SPARROW_SOURCE_KEY = ORIGINAL_SPARROW_SOURCE_KEY; - purgeConfigCaches(); - clearDialogs(); + vi.unstubAllEnvs(); + isCISpy.mockClear(); }); describe("getMetricsDispatcher()", () => { - const MOCK_DISPATCHER_OPTIONS = { - // By setting this to true we avoid the `getMetricsConfig()` logic in these tests. - sendMetrics: true, - offline: false, - }; - - // These tests should never hit the `/user` API endpoint. - const userRequests = mockUserRequest(); + beforeEach(() => { + vi.mocked(getOS).mockReturnValue("foo:bar"); + vi.mocked(getWranglerVersion).mockReturnValue("1.2.3"); + vi.mocked(getOSVersion).mockReturnValue("mock os version"); + vi.mocked(getNodeVersion).mockReturnValue(1); + vi.mocked(getPlatform).mockReturnValue("mock platform"); + vi.mocked(sniffUserAgent).mockReturnValue("npm"); + vi.useFakeTimers({ + now: new Date(2024, 11, 12), + }); + writeMetricsConfig({ + permission: { + enabled: true, + date: new Date(2024, 11, 11), + }, + deviceId: "f82b1f46-eb7b-4154-aa9f-ce95f23b2288", + }); + }); + afterEach(() => { - expect(userRequests.count).toBe(0); + vi.useRealTimers(); }); - describe("identify()", () => { + describe("sendAdhocEvent()", () => { it("should send a request to the default URL", async () => { - const request = mockMetricRequest( - { - event: "identify", - properties: { - category: "Workers", - wranglerVersion, - os: process.platform + ":" + process.arch, - a: 1, - b: 2, - }, - }, - { "Sparrow-Source-Key": "MOCK_KEY" }, - "identify" - ); - const dispatcher = await getMetricsDispatcher(MOCK_DISPATCHER_OPTIONS); - await dispatcher.identify({ a: 1, b: 2 }); + const requests = mockMetricRequest(); - expect(request.count).toBe(1); + const dispatcher = getMetricsDispatcher({ + sendMetrics: true, + }); + dispatcher.sendAdhocEvent("some-event", { a: 1, b: 2 }); + await Promise.all(dispatcher.requests); + expect(requests.count).toBe(1); expect(std.debug).toMatchInlineSnapshot( - `"Metrics dispatcher: Posting data {\\"type\\":\\"identify\\",\\"name\\":\\"identify\\",\\"properties\\":{\\"a\\":1,\\"b\\":2}}"` + `"Metrics dispatcher: Posting data {\\"deviceId\\":\\"f82b1f46-eb7b-4154-aa9f-ce95f23b2288\\",\\"event\\":\\"some-event\\",\\"timestamp\\":1733961600000,\\"properties\\":{\\"category\\":\\"Workers\\",\\"wranglerVersion\\":\\"1.2.3\\",\\"os\\":\\"foo:bar\\",\\"a\\":1,\\"b\\":2}}"` ); expect(std.out).toMatchInlineSnapshot(`""`); expect(std.warn).toMatchInlineSnapshot(`""`); @@ -82,17 +100,15 @@ describe("metrics", () => { }); it("should write a debug log if the dispatcher is disabled", async () => { - const requests = mockMetricRequest({}, {}, "identify"); - const dispatcher = await getMetricsDispatcher({ - ...MOCK_DISPATCHER_OPTIONS, + const requests = mockMetricRequest(); + const dispatcher = getMetricsDispatcher({ sendMetrics: false, }); - await dispatcher.identify({ a: 1, b: 2 }); - await flushPromises(); + dispatcher.sendAdhocEvent("some-event", { a: 1, b: 2 }); expect(requests.count).toBe(0); expect(std.debug).toMatchInlineSnapshot( - `"Metrics dispatcher: Dispatching disabled - would have sent {\\"type\\":\\"identify\\",\\"name\\":\\"identify\\",\\"properties\\":{\\"a\\":1,\\"b\\":2}}."` + `"Metrics dispatcher: Dispatching disabled - would have sent {\\"deviceId\\":\\"f82b1f46-eb7b-4154-aa9f-ce95f23b2288\\",\\"event\\":\\"some-event\\",\\"timestamp\\":1733961600000,\\"properties\\":{\\"category\\":\\"Workers\\",\\"wranglerVersion\\":\\"1.2.3\\",\\"os\\":\\"foo:bar\\",\\"a\\":1,\\"b\\":2}}."` ); expect(std.out).toMatchInlineSnapshot(`""`); expect(std.warn).toMatchInlineSnapshot(`""`); @@ -101,16 +117,18 @@ describe("metrics", () => { it("should write a debug log if the request fails", async () => { msw.use( - http.post("*/identify", async () => { + http.post("*/event", async () => { return HttpResponse.error(); }) ); + const dispatcher = getMetricsDispatcher({ + sendMetrics: true, + }); + dispatcher.sendAdhocEvent("some-event", { a: 1, b: 2 }); + await Promise.all(dispatcher.requests); - const dispatcher = await getMetricsDispatcher(MOCK_DISPATCHER_OPTIONS); - await dispatcher.identify({ a: 1, b: 2 }); - await flushPromises(); expect(std.debug).toMatchInlineSnapshot(` - "Metrics dispatcher: Posting data {\\"type\\":\\"identify\\",\\"name\\":\\"identify\\",\\"properties\\":{\\"a\\":1,\\"b\\":2}} + "Metrics dispatcher: Posting data {\\"deviceId\\":\\"f82b1f46-eb7b-4154-aa9f-ce95f23b2288\\",\\"event\\":\\"some-event\\",\\"timestamp\\":1733961600000,\\"properties\\":{\\"category\\":\\"Workers\\",\\"wranglerVersion\\":\\"1.2.3\\",\\"os\\":\\"foo:bar\\",\\"a\\":1,\\"b\\":2}} Metrics dispatcher: Failed to send request: Failed to fetch" `); expect(std.out).toMatchInlineSnapshot(`""`); @@ -119,11 +137,17 @@ describe("metrics", () => { }); it("should write a warning log if no source key has been provided", async () => { - global.SPARROW_SOURCE_KEY = undefined; - const dispatcher = await getMetricsDispatcher(MOCK_DISPATCHER_OPTIONS); - await dispatcher.identify({ a: 1, b: 2 }); + vi.stubEnv("SPARROW_SOURCE_KEY", undefined); + + const requests = mockMetricRequest(); + const dispatcher = getMetricsDispatcher({ + sendMetrics: true, + }); + dispatcher.sendAdhocEvent("some-event", { a: 1, b: 2 }); + + expect(requests.count).toBe(0); expect(std.debug).toMatchInlineSnapshot( - `"Metrics dispatcher: Source Key not provided. Be sure to initialize before sending events. { type: 'identify', name: 'identify', properties: { a: 1, b: 2 } }"` + `"Metrics dispatcher: Source Key not provided. Be sure to initialize before sending events {\\"deviceId\\":\\"f82b1f46-eb7b-4154-aa9f-ce95f23b2288\\",\\"event\\":\\"some-event\\",\\"timestamp\\":1733961600000,\\"properties\\":{\\"category\\":\\"Workers\\",\\"wranglerVersion\\":\\"1.2.3\\",\\"os\\":\\"foo:bar\\",\\"a\\":1,\\"b\\":2}}"` ); expect(std.out).toMatchInlineSnapshot(`""`); expect(std.warn).toMatchInlineSnapshot(`""`); @@ -131,97 +155,308 @@ describe("metrics", () => { }); }); - describe("sendEvent()", () => { - it("should send a request to the default URL", async () => { - const requests = mockMetricRequest( - { - event: "some-event", - properties: { - category: "Workers", - wranglerVersion, - os: process.platform + ":" + process.arch, - a: 1, - b: 2, - }, - }, - { - "Sparrow-Source-Key": "MOCK_KEY", - }, - "event" - ); - const dispatcher = await getMetricsDispatcher(MOCK_DISPATCHER_OPTIONS); - await dispatcher.sendEvent("some-event", { a: 1, b: 2 }); + it("should keep track of all requests made", async () => { + const requests = mockMetricRequest(); + const dispatcher = getMetricsDispatcher({ + sendMetrics: true, + }); - expect(requests.count).toBe(1); - expect(std.debug).toMatchInlineSnapshot( - `"Metrics dispatcher: Posting data {\\"type\\":\\"event\\",\\"name\\":\\"some-event\\",\\"properties\\":{\\"a\\":1,\\"b\\":2}}"` + dispatcher.sendAdhocEvent("some-event", { a: 1, b: 2 }); + expect(dispatcher.requests.length).toBe(1); + + expect(requests.count).toBe(0); + await Promise.allSettled(dispatcher.requests); + expect(requests.count).toBe(1); + + dispatcher.sendAdhocEvent("another-event", { c: 3, d: 4 }); + expect(dispatcher.requests.length).toBe(2); + + expect(requests.count).toBe(1); + await Promise.allSettled(dispatcher.requests); + expect(requests.count).toBe(2); + }); + + describe("sendCommandEvent()", () => { + const reused = { + wranglerVersion: "1.2.3", + osPlatform: "mock platform", + osVersion: "mock os version", + nodeVersion: 1, + packageManager: "npm", + isFirstUsage: false, + configFileType: "toml", + isCI: false, + isInteractive: true, + argsUsed: [ + "j", + "search", + "xGradualRollouts", + "xJsonConfig", + "xVersions", + ], + argsCombination: "j, search, xGradualRollouts, xJsonConfig, xVersions", + command: "wrangler docs", + args: { + xJsonConfig: true, + j: true, + xVersions: true, + xGradualRollouts: true, + search: [""], + }, + }; + beforeEach(() => { + globalThis.ALGOLIA_APP_ID = "FAKE-ID"; + globalThis.ALGOLIA_PUBLIC_KEY = "FAKE-KEY"; + msw.use( + http.post, { params: string | undefined }>( + `*/1/indexes/developers-cloudflare2/query`, + async ({ request }) => { + vi.advanceTimersByTime(6000); + return HttpResponse.json({ + hits: [ + { + url: `FAKE_DOCS_URL:${await request.text()}`, + }, + ], + }); + }, + { once: true } + ) ); - expect(std.out).toMatchInlineSnapshot(`""`); - expect(std.warn).toMatchInlineSnapshot(`""`); - expect(std.err).toMatchInlineSnapshot(`""`); + // docs has an extra sendMetricsEvent call, just do nothing here + // because we only want to test the top level sendNewEvent + vi.mocked(sendMetricsEvent).mockImplementation(async () => {}); + }); + afterEach(() => { + delete globalThis.ALGOLIA_APP_ID; + delete globalThis.ALGOLIA_PUBLIC_KEY; }); - it("should write a debug log if the dispatcher is disabled", async () => { - const requests = mockMetricRequest({}, {}, "event"); + it("should send a started and completed event", async () => { + writeWranglerConfig(); + const requests = mockMetricRequest(); - const dispatcher = await getMetricsDispatcher({ - ...MOCK_DISPATCHER_OPTIONS, - sendMetrics: false, - }); - await dispatcher.sendEvent("some-event", { a: 1, b: 2 }); - await flushPromises(); + await runWrangler("docs arg"); - expect(requests.count).toBe(0); - expect(std.debug).toMatchInlineSnapshot( - `"Metrics dispatcher: Dispatching disabled - would have sent {\\"type\\":\\"event\\",\\"name\\":\\"some-event\\",\\"properties\\":{\\"a\\":1,\\"b\\":2}}."` + expect(requests.count).toBe(2); + + const expectedStartReq = { + deviceId: "f82b1f46-eb7b-4154-aa9f-ce95f23b2288", + event: "wrangler command started", + timestamp: 1733961600000, + properties: { + amplitude_session_id: 1733961600000, + amplitude_event_id: 0, + ...reused, + }, + }; + expect(std.debug).toContain( + `Posting data ${JSON.stringify(expectedStartReq)}` ); - expect(std.out).toMatchInlineSnapshot(`""`); + const expectedCompleteReq = { + deviceId: "f82b1f46-eb7b-4154-aa9f-ce95f23b2288", + event: "wrangler command completed", + timestamp: 1733961606000, + properties: { + amplitude_session_id: 1733961600000, + amplitude_event_id: 1, + ...reused, + durationMs: 6000, + durationSeconds: 6, + durationMinutes: 0.1, + }, + }; + // command completed + expect(std.debug).toContain( + `Posting data ${JSON.stringify(expectedCompleteReq)}` + ); + expect(std.out).toMatchInlineSnapshot(` + " + Cloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md + Opening a link in your default browser: FAKE_DOCS_URL:{\\"params\\":\\"query=arg&hitsPerPage=1&getRankingInfo=0\\"}" + `); expect(std.warn).toMatchInlineSnapshot(`""`); expect(std.err).toMatchInlineSnapshot(`""`); }); - it("should write a debug log if the request fails", async () => { + it("should send a started and errored event", async () => { + writeWranglerConfig(); + const requests = mockMetricRequest(); msw.use( - http.post("*/event", async () => { - return HttpResponse.error(); - }) + http.post, { params: string | undefined }>( + `*/1/indexes/developers-cloudflare2/query`, + async () => { + vi.advanceTimersByTime(6000); + return HttpResponse.error(); + }, + { once: true } + ) + ); + await expect( + runWrangler("docs arg") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[TypeError: Failed to fetch]` + ); + expect(requests.count).toBe(2); + + const expectedStartReq = { + deviceId: "f82b1f46-eb7b-4154-aa9f-ce95f23b2288", + event: "wrangler command started", + timestamp: 1733961600000, + properties: { + amplitude_session_id: 1733961600000, + amplitude_event_id: 0, + ...reused, + }, + }; + expect(std.debug).toContain( + `Posting data ${JSON.stringify(expectedStartReq)}` ); - const dispatcher = await getMetricsDispatcher(MOCK_DISPATCHER_OPTIONS); - await dispatcher.sendEvent("some-event", { a: 1, b: 2 }); - await flushPromises(); - expect(std.debug).toMatchInlineSnapshot(` - "Metrics dispatcher: Posting data {\\"type\\":\\"event\\",\\"name\\":\\"some-event\\",\\"properties\\":{\\"a\\":1,\\"b\\":2}} - Metrics dispatcher: Failed to send request: Failed to fetch" - `); - expect(std.out).toMatchInlineSnapshot(`""`); - expect(std.warn).toMatchInlineSnapshot(`""`); - expect(std.err).toMatchInlineSnapshot(`""`); - }); - it("should write a warning log if no source key has been provided", async () => { - global.SPARROW_SOURCE_KEY = undefined; - const requests = mockMetricRequest({}, {}, "event"); - const dispatcher = await getMetricsDispatcher(MOCK_DISPATCHER_OPTIONS); - await dispatcher.sendEvent("some-event", { a: 1, b: 2 }); + const expectedErrorReq = { + deviceId: "f82b1f46-eb7b-4154-aa9f-ce95f23b2288", + event: "wrangler command errored", + timestamp: 1733961606000, + properties: { + amplitude_session_id: 1733961600000, + amplitude_event_id: 1, + ...reused, + durationMs: 6000, + durationSeconds: 6, + durationMinutes: 0.1, + errorType: "TypeError", + }, + }; - expect(requests.count).toBe(0); - expect(std.debug).toMatchInlineSnapshot( - `"Metrics dispatcher: Source Key not provided. Be sure to initialize before sending events. { type: 'event', name: 'some-event', properties: { a: 1, b: 2 } }"` + expect(std.debug).toContain( + `Posting data ${JSON.stringify(expectedErrorReq)}` ); - expect(std.out).toMatchInlineSnapshot(`""`); - expect(std.warn).toMatchInlineSnapshot(`""`); - expect(std.err).toMatchInlineSnapshot(`""`); + }); + + it("should mark isCI as true if running in CI", async () => { + isCISpy.mockReturnValue(true); + const requests = mockMetricRequest(); + + await runWrangler("docs arg"); + + expect(requests.count).toBe(2); + expect(std.debug).toContain('isCI":true'); + }); + + it("should mark as non-interactive if running in non-interactive environment", async () => { + setIsTTY(false); + const requests = mockMetricRequest(); + + await runWrangler("docs arg"); + + expect(requests.count).toBe(2); + expect(std.debug).toContain('"isInteractive":false,'); + }); + + describe("banner", () => { + beforeEach(() => { + vi.mocked(getWranglerVersion).mockReturnValue("1.2.3"); + }); + it("should print the banner if current version is different to the stored version", async () => { + writeMetricsConfig({ + permission: { + enabled: true, + date: new Date(2022, 6, 4), + bannerLastShown: "1.2.1", + }, + }); + + const requests = mockMetricRequest(); + + await runWrangler("docs arg"); + expect(std.out).toMatchInlineSnapshot(` + " + Cloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md + Opening a link in your default browser: FAKE_DOCS_URL:{\\"params\\":\\"query=arg&hitsPerPage=1&getRankingInfo=0\\"}" + `); + + expect(requests.count).toBe(2); + }); + it("should not print the banner if current version is the same as the stored version", async () => { + writeMetricsConfig({ + permission: { + enabled: true, + date: new Date(2022, 6, 4), + bannerLastShown: "1.2.3", + }, + }); + const requests = mockMetricRequest(); + await runWrangler("docs arg"); + expect(std.out).not.toContain( + "Cloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md" + ); + expect(requests.count).toBe(2); + }); + it("should print the banner if nothing is stored under bannerLastShown and then store the current version", async () => { + writeMetricsConfig({ + permission: { + enabled: true, + date: new Date(2022, 6, 4), + }, + }); + const requests = mockMetricRequest(); + await runWrangler("docs arg"); + expect(std.out).toMatchInlineSnapshot(` + " + Cloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md + Opening a link in your default browser: FAKE_DOCS_URL:{\\"params\\":\\"query=arg&hitsPerPage=1&getRankingInfo=0\\"}" + `); + expect(requests.count).toBe(2); + const { permission } = readMetricsConfig(); + expect(permission?.bannerLastShown).toEqual("1.2.3"); + }); + it("should not print the banner if telemetry permission is disabled", async () => { + writeMetricsConfig({ + permission: { + enabled: false, + date: new Date(2022, 6, 4), + }, + }); + const requests = mockMetricRequest(); + await runWrangler("docs arg"); + expect(std.out).not.toContain( + "Cloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md" + ); + expect(requests.count).toBe(0); + const { permission } = readMetricsConfig(); + expect(permission?.bannerLastShown).toBeUndefined(); + }); + }); + }); + + describe("redactArgValues()", () => { + it("should redact sensitive values", () => { + const args = { + default: false, + array: ["beep", "boop"], + secretArray: ["beep", "boop"], + // Note how + "secret-array": ["beep", "boop"], + number: 42, + string: "secret", + secretString: "secret", + }; + + const redacted = redactArgValues(args, ["string", "array"]); + expect(redacted).toEqual({ + default: false, + array: ["beep", "boop"], + secretArray: ["", ""], + number: 42, + string: "secret", + secretString: "", + }); }); }); }); describe("getMetricsConfig()", () => { - let isCISpy: MockInstance; - - const { setIsTTY } = useMockIsTTY(); beforeEach(() => { - // Default the mock TTY to interactive for all these tests. - setIsTTY(true); isCISpy = vi.spyOn(CI, "isCI").mockReturnValue(false); }); @@ -237,40 +472,17 @@ describe("metrics", () => { }); }); - it("should return false if running in a CI environment", async () => { - isCISpy.mockReturnValue(true); - expect(await getMetricsConfig({})).toMatchObject({ - enabled: false, - }); - }); - it("should return the sendMetrics argument for enabled if it is defined", async () => { - expect( - await getMetricsConfig({ sendMetrics: false, offline: false }) - ).toMatchObject({ + expect(await getMetricsConfig({ sendMetrics: false })).toMatchObject({ enabled: false, }); - expect( - await getMetricsConfig({ sendMetrics: true, offline: false }) - ).toMatchObject({ + expect(await getMetricsConfig({ sendMetrics: true })).toMatchObject({ enabled: true, }); }); - it("should return enabled false if the process is not interactive", async () => { - setIsTTY(false); - expect( - await getMetricsConfig({ - sendMetrics: undefined, - offline: false, - }) - ).toMatchObject({ - enabled: false, - }); - }); - it("should return enabled true if the user on this device previously agreed to send metrics", async () => { - await writeMetricsConfig({ + writeMetricsConfig({ permission: { enabled: true, date: new Date(2022, 6, 4), @@ -279,7 +491,6 @@ describe("metrics", () => { expect( await getMetricsConfig({ sendMetrics: undefined, - offline: false, }) ).toMatchObject({ enabled: true, @@ -287,7 +498,7 @@ describe("metrics", () => { }); it("should return enabled false if the user on this device previously refused to send metrics", async () => { - await writeMetricsConfig({ + writeMetricsConfig({ permission: { enabled: false, date: new Date(2022, 6, 4), @@ -296,194 +507,236 @@ describe("metrics", () => { expect( await getMetricsConfig({ sendMetrics: undefined, - offline: false, - }) - ).toMatchObject({ - enabled: false, - }); - }); - - it("should accept and store permission granting to send metrics if the user agrees", async () => { - mockConfirm({ - text: "Would you like to help improve Wrangler by sending usage metrics to Cloudflare?", - result: true, - }); - expect( - await getMetricsConfig({ - sendMetrics: undefined, - offline: false, - }) - ).toMatchObject({ - enabled: true, - }); - expect((await readMetricsConfig()).permission).toMatchObject({ - enabled: true, - }); - }); - - it("should accept and store permission declining to send metrics if the user declines", async () => { - mockConfirm({ - text: "Would you like to help improve Wrangler by sending usage metrics to Cloudflare?", - result: false, - }); - expect( - await getMetricsConfig({ - sendMetrics: undefined, - offline: false, }) ).toMatchObject({ enabled: false, }); - expect((await readMetricsConfig()).permission).toMatchObject({ - enabled: false, - }); }); - it("should ignore the config if the permission date is older than the current metrics date", async () => { - mockConfirm({ - text: "Would you like to help improve Wrangler by sending usage metrics to Cloudflare?", - result: false, - }); + it("should print a message if the permission date is older than the current metrics date", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2024, 11, 12)); const OLD_DATE = new Date(2000); - await writeMetricsConfig({ + writeMetricsConfig({ permission: { enabled: true, date: OLD_DATE }, }); expect( await getMetricsConfig({ sendMetrics: undefined, - offline: false, }) ).toMatchObject({ - enabled: false, + enabled: true, }); - const { permission } = await readMetricsConfig(); - expect(permission?.enabled).toBe(false); + const { permission } = readMetricsConfig(); + expect(permission?.enabled).toBe(true); // The date should be updated to today's date - expect(permission?.date).toEqual(CURRENT_METRICS_DATE); + expect(permission?.date).toEqual(new Date(2024, 11, 12)); expect(std.out).toMatchInlineSnapshot(` - "Usage metrics tracking has changed since you last granted permission. - Your choice has been saved in the following file: test-xdg-config/metrics.json. - - You can override the user level setting for a project in \`wrangler.toml\`: - - - to disable sending metrics for a project: \`send_metrics = false\` - - to enable sending metrics for a project: \`send_metrics = true\`" + "Usage metrics tracking has changed since you last granted permission." `); + vi.useRealTimers(); }); }); describe("deviceId", () => { it("should return a deviceId found in the config file", async () => { - await writeMetricsConfig({ deviceId: "XXXX-YYYY-ZZZZ" }); + writeMetricsConfig({ deviceId: "XXXX-YYYY-ZZZZ" }); const { deviceId } = await getMetricsConfig({ sendMetrics: true, - offline: false, }); expect(deviceId).toEqual("XXXX-YYYY-ZZZZ"); - expect((await readMetricsConfig()).deviceId).toEqual(deviceId); + expect(readMetricsConfig().deviceId).toEqual(deviceId); }); it("should create and store a new deviceId if none is found in the config file", async () => { - await writeMetricsConfig({}); + writeMetricsConfig({}); const { deviceId } = await getMetricsConfig({ sendMetrics: true, - offline: false, }); expect(deviceId).toMatch( /[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}/ ); - expect((await readMetricsConfig()).deviceId).toEqual(deviceId); + expect(readMetricsConfig().deviceId).toEqual(deviceId); }); }); + }); - describe("userId", () => { - const userRequests = mockUserRequest(); - it("should return a userId found in a cache file", async () => { - await saveToConfigCache(USER_ID_CACHE_PATH, { - userId: "CACHED_USER_ID", + describe.each(["metrics", "telemetry"])("%s commands", (cmd) => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2024, 11, 12)); + }); + afterEach(() => { + vi.useRealTimers(); + }); + describe(`${cmd} status`, () => { + it("prints the current telemetry status based on the cached metrics config", async () => { + writeMetricsConfig({ + permission: { + enabled: true, + date: new Date(2022, 6, 4), + }, }); - const { userId } = await getMetricsConfig({ - sendMetrics: true, - offline: false, + await runWrangler(`${cmd} status`); + expect(std.out).toContain("Status: Enabled"); + expect(std.out).not.toContain("Status: Disabled"); + writeMetricsConfig({ + permission: { + enabled: false, + date: new Date(2022, 6, 4), + }, }); - expect(userId).toEqual("CACHED_USER_ID"); - expect(userRequests.count).toBe(0); + await runWrangler("telemetry status"); + expect(std.out).toContain("Status: Disabled"); }); - it("should fetch the userId from Cloudflare and store it in a cache file", async () => { - writeAuthConfigFile({ oauth_token: "DUMMY_TOKEN" }); - const { userId } = await getMetricsConfig({ - sendMetrics: true, - offline: false, + it("shows wrangler.toml as the source with send_metrics is present", async () => { + writeMetricsConfig({ + permission: { + enabled: true, + date: new Date(2022, 6, 4), + }, }); - await flushPromises(); - - expect(userId).toEqual("MOCK_USER_ID"); - expect(userRequests.count).toBe(1); + writeWranglerConfig({ send_metrics: false }); + await runWrangler(`${cmd} status`); + expect(std.out).toContain("Status: Disabled (set by wrangler.toml)"); }); - it("should not fetch the userId from Cloudflare if running in `offline` mode", async () => { - writeAuthConfigFile({ oauth_token: "DUMMY_TOKEN" }); - const { userId } = await getMetricsConfig({ - sendMetrics: true, - offline: true, + it("shows environment variable as the source if used", async () => { + writeMetricsConfig({ + permission: { + enabled: true, + date: new Date(2022, 6, 4), + }, }); - expect(userId).toBe(undefined); - expect(userRequests.count).toBe(0); + vi.stubEnv("WRANGLER_SEND_METRICS", "false"); + await runWrangler(`${cmd} status`); + expect(std.out).toContain( + "Status: Disabled (set by environment variable)" + ); }); - }); - }); -}); -function mockUserRequest() { - const requests = { count: 0 }; - beforeEach(() => { - msw.use( - ...mswSuccessOauthHandlers, - http.get("*/user", () => { - requests.count++; - return HttpResponse.json( - { - success: true, - errors: [], - messages: [], - result: { id: "MOCK_USER_ID" }, + it("defaults to enabled if metrics config is not set", async () => { + writeMetricsConfig({}); + await runWrangler(`${cmd} status`); + expect(std.out).toContain("Status: Enabled"); + }); + + it("prioritises environment variable over send_metrics", async () => { + writeMetricsConfig({ + permission: { + enabled: true, + date: new Date(2022, 6, 4), }, - { status: 200 } + }); + writeWranglerConfig({ send_metrics: true }); + vi.stubEnv("WRANGLER_SEND_METRICS", "false"); + await runWrangler(`${cmd} status`); + expect(std.out).toContain( + "Status: Disabled (set by environment variable)" ); - }) - ); - }); - afterEach(() => { - requests.count = 0; + }); + }); + + it(`disables telemetry when "wrangler ${cmd} disable" is run`, async () => { + writeMetricsConfig({ + permission: { + enabled: true, + date: new Date(2022, 6, 4), + }, + }); + await runWrangler(`${cmd} disable`); + expect(std.out).toContain(`Status: Disabled + +Wrangler is no longer collecting telemetry about your usage.`); + expect(readMetricsConfig()).toMatchObject({ + permission: { + enabled: false, + date: new Date(2024, 11, 12), + }, + }); + }); + + it(`doesn't send telemetry when running "wrangler ${cmd} disable"`, async () => { + const requests = mockMetricRequest(); + writeMetricsConfig({ + permission: { + enabled: true, + date: new Date(2022, 6, 4), + }, + }); + await runWrangler(`${cmd} disable`); + expect(requests.count).toBe(0); + expect(std.debug).not.toContain("Metrics dispatcher: Posting data"); + }); + + it(`does send telemetry when running "wrangler ${cmd} enable"`, async () => { + const requests = mockMetricRequest(); + writeMetricsConfig({ + permission: { + enabled: true, + date: new Date(2022, 6, 4), + }, + }); + await runWrangler(`${cmd} enable`); + expect(requests.count).toBe(2); + expect(std.debug).toContain("Metrics dispatcher: Posting data"); + }); + + it(`enables telemetry when "wrangler ${cmd} enable" is run`, async () => { + writeMetricsConfig({ + permission: { + enabled: false, + date: new Date(2022, 6, 4), + }, + }); + await runWrangler(`${cmd} enable`); + expect(std.out).toContain(`Status: Enabled + +Wrangler is now collecting telemetry about your usage. Thank you for helping make Wrangler better 🧡`); + expect(readMetricsConfig()).toMatchObject({ + permission: { + enabled: true, + date: new Date(2024, 11, 12), + }, + }); + }); + + it("doesn't overwrite c3 telemetry config", async () => { + writeMetricsConfig({ + c3permission: { + enabled: false, + date: new Date(2022, 6, 4), + }, + }); + await runWrangler(`${cmd} enable`); + expect(std.out).toContain(`Status: Enabled + +Wrangler is now collecting telemetry about your usage. Thank you for helping make Wrangler better 🧡`); + const config = readMetricsConfig(); + expect(config).toMatchObject({ + c3permission: { + enabled: false, + date: new Date(2022, 6, 4), + }, + permission: { + enabled: true, + date: new Date(2024, 11, 12), + }, + }); + }); }); - return requests; -} +}); -function mockMetricRequest( - body: unknown, - header: unknown, - endpoint: "identify" | "event" -) { +function mockMetricRequest() { const requests = { count: 0 }; msw.use( - http.post( - `*/${endpoint}`, - async ({ request }) => { - requests.count++; - expect(await request.json()).toEqual(body); - expect(request.headers).toContain(header); - return HttpResponse.json({}, { status: 200 }); - }, - { once: true } - ) + http.post(`*/event`, async () => { + requests.count++; + return HttpResponse.json({}, { status: 200 }); + }) ); return requests; } - -// Forces a tick to allow the non-awaited fetch promise to resolve. -function flushPromises(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)); -} diff --git a/packages/wrangler/src/config/index.ts b/packages/wrangler/src/config/index.ts index 0065059cd6c8..36b3c30f0de9 100644 --- a/packages/wrangler/src/config/index.ts +++ b/packages/wrangler/src/config/index.ts @@ -29,7 +29,7 @@ export type { RawEnvironment, } from "./environment"; -function configFormat( +export function configFormat( configPath: string | undefined ): "jsonc" | "toml" | "none" { if (configPath?.endsWith("toml")) { @@ -98,12 +98,7 @@ export function readConfig( } try { - // Load the configuration from disk if available - if (configPath?.endsWith("toml")) { - rawConfig = parseTOML(readFileSync(configPath), configPath); - } else if (configPath?.endsWith("json") || configPath?.endsWith("jsonc")) { - rawConfig = parseJSONC(readFileSync(configPath), configPath); - } + rawConfig = readRawConfig(configPath); } catch (e) { // Swallow parsing errors if we require a pages config file. // At this point, we can't tell if the user intended to provide a Pages config file (and so should see the parsing error) or not (and so shouldn't). @@ -173,6 +168,15 @@ export function readConfig( return config; } +export const readRawConfig = (configPath: string | undefined): RawConfig => { + // Load the configuration from disk if available + if (configPath?.endsWith("toml")) { + return parseTOML(readFileSync(configPath), configPath); + } else if (configPath?.endsWith("json") || configPath?.endsWith("jsonc")) { + return parseJSONC(readFileSync(configPath), configPath); + } + return {}; +}; /** * Modifies the provided config to support python workers, if the entrypoint is a .py file */ diff --git a/packages/wrangler/src/delete.ts b/packages/wrangler/src/delete.ts index e4ce02fe8761..f2e56778d643 100644 --- a/packages/wrangler/src/delete.ts +++ b/packages/wrangler/src/delete.ts @@ -104,7 +104,7 @@ export async function deleteHandler(args: DeleteArgs) { "For Pages, please run `wrangler pages project delete` instead." ); } - await metrics.sendMetricsEvent( + metrics.sendMetricsEvent( "delete worker script", {}, { sendMetrics: config.send_metrics } diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index 8f724c520960..24b8519c37fa 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -390,7 +390,7 @@ async function deployWorker(args: DeployArgs) { targets, }); - await metrics.sendMetricsEvent( + metrics.sendMetricsEvent( "deploy worker script", { usesTypeScript: /\.tsx?$/.test(entry.file), diff --git a/packages/wrangler/src/deployments.ts b/packages/wrangler/src/deployments.ts index 3e14772ed21b..e5f2e59257f2 100644 --- a/packages/wrangler/src/deployments.ts +++ b/packages/wrangler/src/deployments.ts @@ -55,7 +55,7 @@ export async function deployments( scriptName: string | undefined, { send_metrics: sendMetrics }: { send_metrics?: Config["send_metrics"] } = {} ) { - await metrics.sendMetricsEvent( + metrics.sendMetricsEvent( "view deployments", { view: scriptName ? "single" : "all" }, { @@ -197,7 +197,7 @@ export async function rollbackDeployment( rollbackMessage ); - await metrics.sendMetricsEvent( + metrics.sendMetricsEvent( "rollback deployments", { view: scriptName ? "single" : "all" }, { @@ -240,7 +240,7 @@ export async function viewDeployment( { send_metrics: sendMetrics }: { send_metrics?: Config["send_metrics"] } = {}, deploymentId: string | undefined ) { - await metrics.sendMetricsEvent( + metrics.sendMetricsEvent( "view deployments", { view: scriptName ? "single" : "all" }, { diff --git a/packages/wrangler/src/dispatch-namespace.ts b/packages/wrangler/src/dispatch-namespace.ts index a3877aff1336..7a4af74b89fe 100644 --- a/packages/wrangler/src/dispatch-namespace.ts +++ b/packages/wrangler/src/dispatch-namespace.ts @@ -116,7 +116,7 @@ export function workerNamespaceCommands( const config = readConfig(args.config, args); const accountId = await requireAuth(config); await listWorkerNamespaces(accountId); - await metrics.sendMetricsEvent("list dispatch namespaces", { + metrics.sendMetricsEvent("list dispatch namespaces", { sendMetrics: config.send_metrics, }); } @@ -135,7 +135,7 @@ export function workerNamespaceCommands( const config = readConfig(args.config, args); const accountId = await requireAuth(config); await getWorkerNamespaceInfo(accountId, args.name); - await metrics.sendMetricsEvent("view dispatch namespace", { + metrics.sendMetricsEvent("view dispatch namespace", { sendMetrics: config.send_metrics, }); } @@ -155,7 +155,7 @@ export function workerNamespaceCommands( const config = readConfig(args.config, args); const accountId = await requireAuth(config); await createWorkerNamespace(accountId, args.name); - await metrics.sendMetricsEvent("create dispatch namespace", { + metrics.sendMetricsEvent("create dispatch namespace", { sendMetrics: config.send_metrics, }); } @@ -175,7 +175,7 @@ export function workerNamespaceCommands( const config = readConfig(args.config, args); const accountId = await requireAuth(config); await deleteWorkerNamespace(accountId, args.name); - await metrics.sendMetricsEvent("delete dispatch namespace", { + metrics.sendMetricsEvent("delete dispatch namespace", { sendMetrics: config.send_metrics, }); } @@ -201,7 +201,7 @@ export function workerNamespaceCommands( const config = readConfig(args.config, args); const accountId = await requireAuth(config); await renameWorkerNamespace(accountId, args.oldName, args.newName); - await metrics.sendMetricsEvent("rename dispatch namespace", { + metrics.sendMetricsEvent("rename dispatch namespace", { sendMetrics: config.send_metrics, }); } diff --git a/packages/wrangler/src/docs/index.ts b/packages/wrangler/src/docs/index.ts index 4bd4a1b0b3d9..99334d9d80a9 100644 --- a/packages/wrangler/src/docs/index.ts +++ b/packages/wrangler/src/docs/index.ts @@ -45,7 +45,7 @@ export const docs = createCommand({ logger.log(`Opening a link in your default browser: ${urlToOpen}`); await openInBrowser(urlToOpen); - await metrics.sendMetricsEvent("view docs", { + metrics.sendMetricsEvent("view docs", { sendMetrics: config.send_metrics, }); }, diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 8c75f56a27ac..b60d226e032d 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -1,5 +1,6 @@ import module from "node:module"; import os from "node:os"; +import { setTimeout } from "node:timers/promises"; import TOML from "@iarna/toml"; import chalk from "chalk"; import { ProxyAgent, setGlobalDispatcher } from "undici"; @@ -7,7 +8,13 @@ import makeCLI from "yargs"; import { version as wranglerVersion } from "../package.json"; import { ai } from "./ai"; import { cloudchamber } from "./cloudchamber"; -import { configFileName, formatConfigSnippet, loadDotEnv } from "./config"; +import { + configFileName, + findWranglerConfig, + formatConfigSnippet, + loadDotEnv, + readRawConfig, +} from "./config"; import { demandSingleValue } from "./core"; import { CommandRegistry } from "./core/CommandRegistry"; import { createRegisterYargsCommand } from "./core/register-yargs-command"; @@ -67,6 +74,14 @@ import { kvNamespaceNamespace, } from "./kv"; import { logBuildFailure, logger, LOGGER_LEVELS } from "./logger"; +import { getMetricsDispatcher } from "./metrics"; +import { + metricsAlias, + telemetryDisableCommand, + telemetryEnableCommand, + telemetryNamespace, + telemetryStatusCommand, +} from "./metrics/commands"; import { mTlsCertificateCommands } from "./mtls-certificate/cli"; import { writeOutput } from "./output"; import { pages } from "./pages"; @@ -323,11 +338,6 @@ export function createCLIParser(argv: string[]) { alias: ["x-versions", "experimental-gradual-rollouts"], }) .check((args) => { - // Update logger level, before we do any logging - if (Object.keys(LOGGER_LEVELS).includes(args.logLevel as string)) { - logger.loggerLevel = args.logLevel as LoggerLevel; - } - // Grab locally specified env params from `.env` file const loaded = loadDotEnv(".env", args.env); for (const [key, value] of Object.entries(loaded?.parsed ?? {})) { @@ -970,6 +980,30 @@ export function createCLIParser(argv: string[]) { ]); registry.registerNamespace("whoami"); + registry.define([ + { + command: "wrangler telemetry", + definition: telemetryNamespace, + }, + { + command: "wrangler metrics", + definition: metricsAlias, + }, + { + command: "wrangler telemetry disable", + definition: telemetryDisableCommand, + }, + { + command: "wrangler telemetry enable", + definition: telemetryEnableCommand, + }, + { + command: "wrangler telemetry status", + definition: telemetryStatusCommand, + }, + ]); + registry.registerNamespace("telemetry"); + /******************************************************/ /* DEPRECATED COMMANDS */ /******************************************************/ @@ -1054,11 +1088,18 @@ export function createCLIParser(argv: string[]) { export async function main(argv: string[]): Promise { setupSentry(); + const startTime = Date.now(); const wrangler = createCLIParser(argv); - + let command: string | undefined; + let metricsArgs: Record | undefined; + let dispatcher: ReturnType | undefined; // Register Yargs middleware to record command as Sentry breadcrumb let recordedCommand = false; const wranglerWithMiddleware = wrangler.middleware((args) => { + // Update logger level, before we do any logging + if (Object.keys(LOGGER_LEVELS).includes(args.logLevel as string)) { + logger.loggerLevel = args.logLevel as LoggerLevel; + } // Middleware called for each sub-command, but only want to record once if (recordedCommand) { return; @@ -1066,15 +1107,47 @@ export async function main(argv: string[]): Promise { recordedCommand = true; // `args._` doesn't include any positional arguments (e.g. script name, // key to fetch) or flags - addBreadcrumb(`wrangler ${args._.join(" ")}`); + + try { + const configPath = args.config ?? findWranglerConfig(process.cwd()); + const rawConfig = readRawConfig(args.config); + dispatcher = getMetricsDispatcher({ + sendMetrics: rawConfig.send_metrics, + configPath, + }); + } catch (e) { + // If we can't parse the config, we can't send metrics + logger.debug("Failed to parse config. Disabling metrics dispatcher.", e); + } + + command = `wrangler ${args._.join(" ")}`; + metricsArgs = args; + addBreadcrumb(command); + // NB despite 'applyBeforeValidation = true', this runs *after* yargs 'validates' options, + // e.g. if a required arg is missing, yargs will error out before we send any events :/ + dispatcher?.sendCommandEvent("wrangler command started", { + command, + args, + }); }, /* applyBeforeValidation */ true); let cliHandlerThrew = false; try { await wranglerWithMiddleware.parse(); + + const durationMs = Date.now() - startTime; + + dispatcher?.sendCommandEvent("wrangler command completed", { + command, + args: metricsArgs, + durationMs, + durationSeconds: durationMs / 1000, + durationMinutes: durationMs / 1000 / 60, + }); } catch (e) { cliHandlerThrew = true; let mayReport = true; + let errorType: string | undefined; logger.log(""); // Just adds a bit of space if (e instanceof CommandLineArgsError) { @@ -1085,6 +1158,7 @@ export async function main(argv: string[]): Promise { await createCLIParser([...argv, "--help"]).parse(); } else if (isAuthenticationError(e)) { mayReport = false; + errorType = "AuthenticationError"; logger.log(formatMessage(e)); const envAuth = getAuthFromEnv(); if (envAuth !== undefined && "apiToken" in envAuth) { @@ -1131,9 +1205,12 @@ export async function main(argv: string[]): Promise { ); } else if (isBuildFailure(e)) { mayReport = false; + errorType = "BuildFailure"; + logBuildFailure(e.errors, e.warnings); } else if (isBuildFailureFromCause(e)) { mayReport = false; + errorType = "BuildFailure"; logBuildFailure(e.cause.errors, e.cause.warnings); } else { let loggableException = e; @@ -1174,6 +1251,18 @@ export async function main(argv: string[]): Promise { await captureGlobalException(e); } + const durationMs = Date.now() - startTime; + + dispatcher?.sendCommandEvent("wrangler command errored", { + command, + args: metricsArgs, + durationMs, + durationSeconds: durationMs / 1000, + durationMinutes: durationMs / 1000 / 60, + errorType: + errorType ?? (e instanceof Error ? e.constructor.name : undefined), + }); + throw e; } finally { try { @@ -1190,6 +1279,10 @@ export async function main(argv: string[]): Promise { } await closeSentry(); + await Promise.race([ + await Promise.allSettled(dispatcher?.requests ?? []), + setTimeout(1000), // Ensure we don't hang indefinitely + ]); } catch (e) { logger.error(e); // Only re-throw if we haven't already re-thrown an exception from a diff --git a/packages/wrangler/src/init.ts b/packages/wrangler/src/init.ts index 3efd5792681a..e7777a8fb8df 100644 --- a/packages/wrangler/src/init.ts +++ b/packages/wrangler/src/init.ts @@ -15,6 +15,7 @@ import { getC3CommandFromEnv } from "./environment-variables/misc-variables"; import { CommandLineArgsError, FatalError, UserError } from "./errors"; import { getGitVersioon, initializeGit, isInsideGitRepo } from "./git-client"; import { logger } from "./logger"; +import { readMetricsConfig } from "./metrics/metrics-config"; import { getPackageManager } from "./package-manager"; import { parsePackageJSON, parseTOML, readFileSync } from "./parse"; import { getBasePath } from "./paths"; @@ -280,8 +281,14 @@ export async function initHandler(args: InitArgs) { ); logger.log(`🌀 Running ${replacementC3Command}...`); + // if telemetry is disabled in wrangler, prevent c3 from sending metrics too + const metricsConfig = readMetricsConfig(); + await execa(packageManager.type, c3Arguments, { stdio: "inherit", + ...(metricsConfig.permission?.enabled === false && { + env: { CREATE_CLOUDFLARE_TELEMETRY_DISABLED: "1" }, + }), }); return; diff --git a/packages/wrangler/src/kv/index.ts b/packages/wrangler/src/kv/index.ts index 248d13a8dcdd..b30c913e531d 100644 --- a/packages/wrangler/src/kv/index.ts +++ b/packages/wrangler/src/kv/index.ts @@ -134,7 +134,7 @@ export const kvNamespaceCreateCommand = createCommand({ logger.log(`🌀 Creating namespace with title "${title}"`); const namespaceId = await createKVNamespace(accountId, title); - await metrics.sendMetricsEvent("create kv namespace", { + metrics.sendMetricsEvent("create kv namespace", { sendMetrics: config.send_metrics, }); @@ -180,7 +180,7 @@ export const kvNamespaceListCommand = createCommand({ // TODO: we should show bindings if they exist for given ids logger.log(JSON.stringify(await listKVNamespaces(accountId), null, " ")); - await metrics.sendMetricsEvent("list kv namespaces", { + metrics.sendMetricsEvent("list kv namespaces", { sendMetrics: config.send_metrics, }); }, @@ -231,7 +231,7 @@ export const kvNamespaceDeleteCommand = createCommand({ logger.log(`Deleting KV namespace ${id}.`); await deleteKVNamespace(accountId, id); logger.log(`Deleted KV namespace ${id}.`); - await metrics.sendMetricsEvent("delete kv namespace", { + metrics.sendMetricsEvent("delete kv namespace", { sendMetrics: config.send_metrics, }); @@ -375,7 +375,7 @@ export const kvKeyPutCommand = createCommand({ metricEvent = "write kv key-value"; } - await metrics.sendMetricsEvent(metricEvent, { + metrics.sendMetricsEvent(metricEvent, { sendMetrics: config.send_metrics, }); }, @@ -449,7 +449,7 @@ export const kvKeyListCommand = createCommand({ } logger.log(JSON.stringify(result, undefined, 2)); - await metrics.sendMetricsEvent(metricEvent, { + metrics.sendMetricsEvent(metricEvent, { sendMetrics: config.send_metrics, }); }, @@ -544,7 +544,7 @@ export const kvKeyGetCommand = createCommand({ } else { process.stdout.write(bufferKVValue); } - await metrics.sendMetricsEvent(metricEvent, { + metrics.sendMetricsEvent(metricEvent, { sendMetrics: config.send_metrics, }); }, @@ -610,7 +610,7 @@ export const kvKeyDeleteCommand = createCommand({ await deleteKVKeyValue(accountId, namespaceId, key); metricEvent = "delete kv key-value"; } - await metrics.sendMetricsEvent(metricEvent, { + metrics.sendMetricsEvent(metricEvent, { sendMetrics: config.send_metrics, }); }, @@ -751,7 +751,7 @@ export const kvBulkPutCommand = createCommand({ metricEvent = "write kv key-values (bulk)"; } - await metrics.sendMetricsEvent(metricEvent, { + metrics.sendMetricsEvent(metricEvent, { sendMetrics: config.send_metrics, }); logger.log("Success!"); @@ -865,7 +865,7 @@ export const kvBulkDeleteCommand = createCommand({ metricEvent = "delete kv key-values (bulk)"; } - await metrics.sendMetricsEvent(metricEvent, { + metrics.sendMetricsEvent(metricEvent, { sendMetrics: config.send_metrics, }); diff --git a/packages/wrangler/src/metrics/commands.ts b/packages/wrangler/src/metrics/commands.ts new file mode 100644 index 000000000000..1098f6f4a625 --- /dev/null +++ b/packages/wrangler/src/metrics/commands.ts @@ -0,0 +1,86 @@ +import chalk from "chalk"; +import { + createAlias, + createCommand, + createNamespace, +} from "../core/create-command"; +import { getWranglerSendMetricsFromEnv } from "../environment-variables/misc-variables"; +import { logger } from "../logger"; +import { readMetricsConfig, updateMetricsPermission } from "./metrics-config"; + +export const telemetryNamespace = createNamespace({ + metadata: { + description: "📈 Configure whether Wrangler collects telemetry", + owner: "Workers: Authoring and Testing", + status: "stable", + hidden: true, + }, +}); + +export const metricsAlias = createAlias({ + aliasOf: "wrangler telemetry", +}); + +export const telemetryDisableCommand = createCommand({ + metadata: { + description: "Disable Wrangler telemetry collection", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + async handler() { + updateMetricsPermission(false); + logTelemetryStatus(false); + logger.log( + "Wrangler is no longer collecting telemetry about your usage.\n" + ); + }, +}); + +export const telemetryEnableCommand = createCommand({ + metadata: { + description: "Enable Wrangler telemetry collection", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + async handler() { + updateMetricsPermission(true); + logTelemetryStatus(true); + logger.log( + "Wrangler is now collecting telemetry about your usage. Thank you for helping make Wrangler better 🧡\n" + ); + }, +}); + +export const telemetryStatusCommand = createCommand({ + metadata: { + description: "Check whether Wrangler telemetry collection is enabled", + owner: "Workers: Authoring and Testing", + status: "stable", + }, + async handler(_, { config }) { + const savedConfig = readMetricsConfig(); + const sendMetricsEnv = getWranglerSendMetricsFromEnv(); + if (config.send_metrics !== undefined || sendMetricsEnv !== undefined) { + const resolvedPermission = + sendMetricsEnv !== undefined + ? sendMetricsEnv === "true" + : config.send_metrics; + logger.log( + `Status: ${resolvedPermission ? chalk.green("Enabled") : chalk.red("Disabled")} (set by ${sendMetricsEnv !== undefined ? "environment variable" : "wrangler.toml"})\n` + ); + } else { + logTelemetryStatus(savedConfig.permission?.enabled ?? true); + } + logger.log( + "To configure telemetry globally on this machine, you can run `wrangler telemetry disable / enable`.\n" + + "You can override this for individual projects with the environment variable `WRANGLER_SEND_METRICS=true/false`.\n" + + "Learn more at https://github.com/cloudflare/workers-sdk/tree/main/telemetry.md\n" + ); + }, +}); + +const logTelemetryStatus = (enabled: boolean) => { + logger.log( + `Status: ${enabled ? chalk.green("Enabled") : chalk.red("Disabled")}\n` + ); +}; diff --git a/packages/wrangler/src/metrics/helpers.ts b/packages/wrangler/src/metrics/helpers.ts new file mode 100644 index 000000000000..bd65ceebdb45 --- /dev/null +++ b/packages/wrangler/src/metrics/helpers.ts @@ -0,0 +1,35 @@ +import os from "node:os"; +import { version as wranglerVersion } from "../../package.json"; + +export function getWranglerVersion() { + return wranglerVersion; +} +// used by "new" metrics +export function getPlatform() { + const platform = os.platform(); + + switch (platform) { + case "win32": + return "Windows"; + case "darwin": + return "Mac OS"; + case "linux": + return "Linux"; + default: + return `Others: ${platform}`; + } +} + +// used by "old" metrics +export function getOS() { + return process.platform + ":" + process.arch; +} + +export function getOSVersion() { + return os.version(); +} + +export function getNodeVersion() { + const nodeVersion = process.versions.node; + return parseInt(nodeVersion.split(".")[0]); +} diff --git a/packages/wrangler/src/metrics/metrics-config.ts b/packages/wrangler/src/metrics/metrics-config.ts index 258846bffd74..9cdb3766f96e 100644 --- a/packages/wrangler/src/metrics/metrics-config.ts +++ b/packages/wrangler/src/metrics/metrics-config.ts @@ -1,14 +1,9 @@ import { randomUUID } from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { fetchResult } from "../cfetch"; -import { getConfigCache, saveToConfigCache } from "../config-cache"; -import { confirm } from "../dialogs"; import { getWranglerSendMetricsFromEnv } from "../environment-variables/misc-variables"; import { getGlobalWranglerConfigPath } from "../global-wrangler-config-path"; -import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; -import { getAPIToken } from "../user"; /** * The date that the metrics being gathered was last updated in a way that would require @@ -20,7 +15,6 @@ import { getAPIToken } from "../user"; * gathering. */ export const CURRENT_METRICS_DATE = new Date(2022, 6, 4); -export const USER_ID_CACHE_PATH = "user-id.json"; export interface MetricsConfigOptions { /** @@ -30,9 +24,9 @@ export interface MetricsConfigOptions { */ sendMetrics?: boolean; /** - * When true, do not make any CF API requests. + * Path to wrangler configuration file, if it exists. Used for configFileType property */ - offline?: boolean; + configPath?: string | undefined; } /** @@ -43,8 +37,6 @@ export interface MetricsConfig { enabled: boolean; /** A UID that identifies this user and machine pair for Wrangler. */ deviceId: string; - /** The currently logged in user - undefined if not logged in. */ - userId: string | undefined; } /** @@ -56,20 +48,14 @@ export interface MetricsConfig { * The permissions define whether we can send metrics or not. They can come from a variety of places: * - the `send_metrics` setting in `wrangler.toml` * - a cached response from the current user - * - prompting the user to opt-in to metrics + * - the `WRANGLER_SEND_METRICS` environment variable * - * If the user was prompted to opt-in, then their response is cached in the metrics config file. - * - * If the current process is not interactive then we will cannot prompt the user and just assume - * we cannot send metrics if there is no cached or project-level preference available. */ -export async function getMetricsConfig({ +export function getMetricsConfig({ sendMetrics, - offline = false, -}: MetricsConfigOptions): Promise { +}: MetricsConfigOptions): MetricsConfig { const config = readMetricsConfig(); const deviceId = getDeviceId(config); - const userId = await getUserId(offline); // If the WRANGLER_SEND_METRICS environment variable has been set use that // and ignore everything else. @@ -78,21 +64,20 @@ export async function getMetricsConfig({ return { enabled: sendMetricsEnv.toLowerCase() === "true", deviceId, - userId, }; } // If the project is explicitly set the `send_metrics` options in `wrangler.toml` // then use that and ignore any user preference. if (sendMetrics !== undefined) { - return { enabled: sendMetrics, deviceId, userId }; + return { enabled: sendMetrics, deviceId }; } // Get the user preference from the metrics config. const permission = config.permission; if (permission !== undefined) { if (new Date(permission.date) >= CURRENT_METRICS_DATE) { - return { enabled: permission.enabled, deviceId, userId }; + return { enabled: permission.enabled, deviceId }; } else if (permission.enabled) { logger.log( "Usage metrics tracking has changed since you last granted permission." @@ -100,33 +85,16 @@ export async function getMetricsConfig({ } } - // We couldn't get the metrics permission from the project-level nor the user-level config. - // If we are not interactive or in a CI build then just bail out. - if (isNonInteractiveOrCI()) { - return { enabled: false, deviceId, userId }; - } - - // Otherwise, let's ask the user and store the result in the metrics config. - const enabled = await confirm( - "Would you like to help improve Wrangler by sending usage metrics to Cloudflare?" - ); - logger.log( - `Your choice has been saved in the following file: ${path.relative( - process.cwd(), - getMetricsConfigPath() - )}.\n\n` + - " You can override the user level setting for a project in `wrangler.toml`:\n\n" + - " - to disable sending metrics for a project: `send_metrics = false`\n" + - " - to enable sending metrics for a project: `send_metrics = true`" - ); + // Otherwise, default to true writeMetricsConfig({ + ...config, permission: { - enabled, - date: CURRENT_METRICS_DATE, + enabled: true, + date: new Date(), }, deviceId, }); - return { enabled, deviceId, userId }; + return { enabled: true, deviceId }; } /** @@ -158,6 +126,15 @@ export function readMetricsConfig(): MetricsConfigFile { } } +export function updateMetricsPermission(enabled: boolean) { + const config = readMetricsConfig(); + config.permission = { + enabled, + date: new Date(), + }; + writeMetricsConfig(config); +} + /** * Get the path to the metrics config file. */ @@ -174,6 +151,14 @@ export interface MetricsConfigFile { enabled: boolean; /** The date that this permission was set. */ date: Date; + /** Version number the banner was last shown - only show on version update */ + bannerLastShown?: string; + }; + c3permission?: { + /** True if c3 should send metrics to Cloudflare. */ + enabled: boolean; + /** The date that this permission was set. */ + date: Date; }; /** A unique UUID that identifies this device for metrics purposes. */ deviceId?: string; @@ -193,42 +178,3 @@ function getDeviceId(config: MetricsConfigFile) { } return deviceId; } - -/** - * Returns the ID of the current user, which will be sent with each event. - * - * The ID is retrieved from the CF API `/user` endpoint if the user is authenticated and then - * stored in the `node_modules/.cache`. - * - * If it is not possible to retrieve the ID (perhaps the user is not logged in) then we just use - * `undefined`. - */ -async function getUserId(offline: boolean) { - // Get the userId from the cache. - // If it has not been found in the cache and we are not offline then make an API call to get it. - // If we can't work in out then just use `anonymous`. - let userId = getConfigCache<{ userId: string }>(USER_ID_CACHE_PATH).userId; - if (userId === undefined && !offline) { - userId = await fetchUserId(); - if (userId !== undefined) { - saveToConfigCache(USER_ID_CACHE_PATH, { userId }); - } - } - return userId; -} - -/** - * Ask the Cloudflare API for the User ID of the current user. - * - * We will only do this if we are not "offline", e.g. not running `wrangler dev --local`. - * Quietly return undefined if anything goes wrong. - */ -async function fetchUserId(): Promise { - try { - return getAPIToken() - ? (await fetchResult<{ id: string }>("/user")).id - : undefined; - } catch (e) { - return undefined; - } -} diff --git a/packages/wrangler/src/metrics/metrics-dispatcher.ts b/packages/wrangler/src/metrics/metrics-dispatcher.ts index 8ba5de4f82ee..2df331f628aa 100644 --- a/packages/wrangler/src/metrics/metrics-dispatcher.ts +++ b/packages/wrangler/src/metrics/metrics-dispatcher.ts @@ -1,80 +1,156 @@ +import chalk from "chalk"; import { fetch } from "undici"; -import { version as wranglerVersion } from "../../package.json"; +import { configFormat } from "../config"; +import isInteractive from "../is-interactive"; import { logger } from "../logger"; -import { getMetricsConfig } from "./metrics-config"; +import { sniffUserAgent } from "../package-manager"; +import { CI } from "./../is-ci"; +import { + getNodeVersion, + getOS, + getOSVersion, + getPlatform, + getWranglerVersion, +} from "./helpers"; +import { + getMetricsConfig, + readMetricsConfig, + writeMetricsConfig, +} from "./metrics-config"; import type { MetricsConfigOptions } from "./metrics-config"; +import type { CommonEventProperties, Events } from "./types"; -// The SPARROW_SOURCE_KEY is provided at esbuild time as a `define` for production and beta -// releases. Otherwise it is left undefined, which automatically disables metrics requests. -declare const SPARROW_SOURCE_KEY: string; const SPARROW_URL = "https://sparrow.cloudflare.com"; -export async function getMetricsDispatcher(options: MetricsConfigOptions) { +export function getMetricsDispatcher(options: MetricsConfigOptions) { + // The SPARROW_SOURCE_KEY will be provided at build time through esbuild's `define` option + // No events will be sent if the env `SPARROW_SOURCE_KEY` is not provided and the value will be set to an empty string instead. + const SPARROW_SOURCE_KEY = process.env.SPARROW_SOURCE_KEY ?? ""; + const requests: Array> = []; + const wranglerVersion = getWranglerVersion(); + const amplitude_session_id = Date.now(); + let amplitude_event_id = 0; + + /** We redact strings in arg values, unless they are named here */ + const allowList = { + // applies to all commands + // use camelCase version + "*": ["format", "logLevel"], + // specific commands + tail: ["status"], + }; + return { + // TODO: merge two sendEvent functions once all commands use defineCommand and get a global dispatcher /** - * Dispatch a event to the analytics target. + * This doesn't have a session id and is not tied to the command events. * * The event should follow these conventions * - name is of the form `[action] [object]` (lower case) * - additional properties are camelCased */ - async sendEvent(name: string, properties: Properties = {}): Promise { - await dispatch({ type: "event", name, properties }); + sendAdhocEvent(name: string, properties: Properties = {}) { + dispatch({ + name, + properties: { + category: "Workers", + wranglerVersion, + os: getOS(), + ...properties, + }, + }); }, /** - * Dispatch a user profile information to the analytics target. + * Dispatches `wrangler command started / completed / errored` events * - * This call can be used to inform the analytics target of relevant properties associated - * with the current user. + * This happens on every command execution. When all commands use defineCommand, + * we should use that to provide the dispatcher on all handlers, and change all + * `sendEvent` calls to use this method. */ - async identify(properties: Properties): Promise { - await dispatch({ type: "identify", name: "identify", properties }); + sendCommandEvent( + name: EventName, + properties: Omit< + Extract["properties"], + keyof CommonEventProperties + > + ) { + try { + if ( + properties.command === "wrangler telemetry disable" || + properties.command === "wrangler metrics disable" + ) { + return; + } + printMetricsBanner(); + const argsUsed = sanitiseUserInput(properties.args ?? {}); + const argsCombination = argsUsed.sort().join(", "); + const commonEventProperties: CommonEventProperties = { + amplitude_session_id, + amplitude_event_id: amplitude_event_id++, + wranglerVersion, + osPlatform: getPlatform(), + osVersion: getOSVersion(), + nodeVersion: getNodeVersion(), + packageManager: sniffUserAgent(), + isFirstUsage: readMetricsConfig().permission === undefined, + configFileType: configFormat(options.configPath), + isCI: CI.isCI(), + isInteractive: isInteractive(), + argsUsed, + argsCombination, + }; + // get the args where we don't want to redact their values + const allowedArgs = getAllowedArgs(allowList, properties.command ?? ""); + properties.args = redactArgValues(properties.args ?? {}, allowedArgs); + dispatch({ + name, + properties: { + ...commonEventProperties, + ...properties, + }, + }); + } catch (err) { + logger.debug("Error sending metrics event", err); + } + }, + + get requests() { + return requests; }, }; - async function dispatch(event: { - type: "identify" | "event"; - name: string; - properties: Properties; - }): Promise { - if (!SPARROW_SOURCE_KEY) { + function dispatch(event: { name: string; properties: Properties }) { + const metricsConfig = getMetricsConfig(options); + const body = { + deviceId: metricsConfig.deviceId, + event: event.name, + timestamp: Date.now(), + properties: event.properties, + }; + + if (!metricsConfig.enabled) { logger.debug( - "Metrics dispatcher: Source Key not provided. Be sure to initialize before sending events.", - event + `Metrics dispatcher: Dispatching disabled - would have sent ${JSON.stringify( + body + )}.` ); return; } - // Lazily get the config for this dispatcher only when an event is being dispatched. - // We must await this since it might trigger user interaction that would break other UI - // in Wrangler if it was allowed to run in parallel. - const metricsConfig = await getMetricsConfig(options); - if (!metricsConfig.enabled) { + if (!SPARROW_SOURCE_KEY) { logger.debug( - `Metrics dispatcher: Dispatching disabled - would have sent ${JSON.stringify( - event - )}.` + "Metrics dispatcher: Source Key not provided. Be sure to initialize before sending events", + JSON.stringify(body) ); return; } - logger.debug(`Metrics dispatcher: Posting data ${JSON.stringify(event)}`); - const body = JSON.stringify({ - deviceId: metricsConfig.deviceId, - userId: metricsConfig.userId, - event: event.name, - properties: { - category: "Workers", - wranglerVersion, - os: process.platform + ":" + process.arch, - ...event.properties, - }, - }); + logger.debug(`Metrics dispatcher: Posting data ${JSON.stringify(body)}`); - // Do not await this fetch call. - // Just fire-and-forget, otherwise we might slow down the rest of Wrangler. - fetch(`${SPARROW_URL}/api/v1/${event.type}`, { + // Don't await fetch but make sure requests are resolved (with a timeout) + // before exiting Wrangler + const request = fetch(`${SPARROW_URL}/api/v1/event`, { method: "POST", headers: { Accept: "*/*", @@ -83,14 +159,109 @@ export async function getMetricsDispatcher(options: MetricsConfigOptions) { }, mode: "cors", keepalive: true, - body, - }).catch((e) => { - logger.debug( - "Metrics dispatcher: Failed to send request:", - (e as Error).message + body: JSON.stringify(body), + }) + .then((res) => { + if (!res.ok) { + logger.debug( + "Metrics dispatcher: Failed to send request:", + res.statusText + ); + } + }) + .catch((e) => { + logger.debug( + "Metrics dispatcher: Failed to send request:", + (e as Error).message + ); + }); + + requests.push(request); + } + + function printMetricsBanner() { + const metricsConfig = readMetricsConfig(); + if ( + metricsConfig.permission?.enabled && + metricsConfig.permission?.bannerLastShown !== wranglerVersion + ) { + logger.log( + chalk.gray( + `\nCloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md` + ) ); - }); + metricsConfig.permission.bannerLastShown = wranglerVersion; + writeMetricsConfig(metricsConfig); + } } } export type Properties = Record; + +const normalise = (arg: string) => { + const camelize = (str: string) => + str.replace(/-./g, (x) => x[1].toUpperCase()); + return camelize(arg.replace("experimental", "x")); +}; + +const exclude = new Set(["$0", "_"]); +/** just some pretty naive cleaning so we don't send "experimental-versions", "experimentalVersions", "x-versions" and "xVersions" etc. */ +const sanitiseUserInput = (argsWithValues: Record) => { + const result: string[] = []; + const args = Object.keys(argsWithValues); + for (const arg of args) { + if (exclude.has(arg)) { + continue; + } + if ( + typeof argsWithValues[arg] === "boolean" && + argsWithValues[arg] === false + ) { + continue; + } + + const normalisedArg = normalise(arg); + if (result.includes(normalisedArg)) { + continue; + } + result.push(normalisedArg); + } + return result; +}; + +const getAllowedArgs = ( + allowList: Record & { "*": string[] }, + key: string +) => { + const commandSpecific = allowList[key] ?? []; + return [...commandSpecific, ...allowList["*"]]; +}; +export const redactArgValues = ( + args: Record, + allowedKeys: string[] +) => { + const result: Record = {}; + + for (const [k, value] of Object.entries(args)) { + const key = normalise(k); + if (exclude.has(key)) { + continue; + } + if ( + typeof value === "number" || + typeof value === "boolean" || + allowedKeys.includes(normalise(key)) + ) { + result[key] = value; + } else if (typeof value === "string") { + result[key] = ""; + } else if (Array.isArray(value)) { + result[key] = value.map((v) => + typeof v === "string" ? "" : v + ); + } else { + result[key] = value; + } + } + return result; +}; diff --git a/packages/wrangler/src/metrics/send-event.ts b/packages/wrangler/src/metrics/send-event.ts index bdde7d86eea4..47498068b665 100644 --- a/packages/wrangler/src/metrics/send-event.ts +++ b/packages/wrangler/src/metrics/send-event.ts @@ -88,34 +88,28 @@ export type EventNames = * * This overload assumes that you do not want to configure analytics with options. */ -export function sendMetricsEvent(event: EventNames): Promise; +export function sendMetricsEvent(event: EventNames): void; /** * Send a metrics event, with no extra properties, to Cloudflare, if usage tracking is enabled. */ export function sendMetricsEvent( event: EventNames, options: MetricsConfigOptions -): Promise; -/** - * Send a metrics event to Cloudflare, if usage tracking is enabled. - * - * Generally you should pass the `send_metrics` property from the wrangler.toml config here, - * which would override any user permissions. - */ +): void; export function sendMetricsEvent( event: EventNames, properties: Properties, options: MetricsConfigOptions -): Promise; -export async function sendMetricsEvent( +): void; +export function sendMetricsEvent( event: EventNames, ...args: [] | [MetricsConfigOptions] | [Properties, MetricsConfigOptions] -): Promise { +): void { try { const options = args.pop() ?? {}; const properties = (args.pop() ?? {}) as Properties; - const metricsDispatcher = await getMetricsDispatcher(options); - await metricsDispatcher.sendEvent(event, properties); + const metricsDispatcher = getMetricsDispatcher(options); + metricsDispatcher.sendAdhocEvent(event, properties); } catch (err) { logger.debug("Error sending metrics event", err); } diff --git a/packages/wrangler/src/metrics/types.ts b/packages/wrangler/src/metrics/types.ts new file mode 100644 index 000000000000..eeece4d58eb1 --- /dev/null +++ b/packages/wrangler/src/metrics/types.ts @@ -0,0 +1,117 @@ +import type { configFormat } from "../config"; +import type { sniffUserAgent } from "../package-manager"; + +export type CommonEventProperties = { + /** The version of the Wrangler client that is sending the event. */ + wranglerVersion: string; + /** + * The platform that the Wrangler client is running on. + */ + osPlatform: string; + /** + * The platform version that the Wrangler client is running on. + */ + osVersion: string; + /** + * The package manager that the Wrangler client is using. + */ + packageManager: ReturnType; + /** + * The major version of node that the Wrangler client is running on. + */ + nodeVersion: number; + /** + * Whether this is the first time the user has used the wrangler client. + */ + isFirstUsage: boolean; + /** + * What format is the configuration file? No content from the actual configuration file is sent. + */ + configFileType: ReturnType; + /** + * Randomly generated id to tie together started, completed or errored events from one command run + */ + amplitude_session_id: number; + /** + * Tracks the order of events in a session (one command run = one session) + */ + amplitude_event_id: number; + /** + * Whether the Wrangler client is running in CI + */ + isCI: boolean; + /** + * Whether the Wrangler client is running in an interactive instance + */ + isInteractive: boolean; + /** + * A list of normalised argument names/flags that were passed in or are set by default. + * Excludes boolean flags set to false. + */ + argsUsed: string[]; + /** + * Same as argsUsed except concatenated for convenience in Amplitude + */ + argsCombination: string; +}; + +/** We send a metrics event at the start and end of a command run */ +export type Events = + | { + name: "wrangler command started"; + properties: CommonEventProperties & { + /** + * The command that was used, e.g. `wrangler dev` + */ + command: string; + /** + * The args and flags that were passed in when running the command. + * All user-inputted string values are redacted, except for some cases where there are set options. + */ + args: Record; + }; + } + | { + name: "wrangler command completed"; + properties: CommonEventProperties & { + /** + * The command that was used, e.g. `wrangler dev` + */ + command: string | undefined; + /** + * The args and flags that were passed in when running the command. + * All user-inputted string values are redacted, except for some cases where there are set options. + */ + args: Record | undefined; + /** + * The time elapsed between the "wrangler command started" and "wrangler command completed" events + */ + durationMs: number; + durationMinutes: number; + durationSeconds: number; + }; + } + | { + name: "wrangler command errored"; + properties: CommonEventProperties & { + /** + * The command that was used, e.g. `wrangler dev` + */ + command: string | undefined; + /** + * The args and flags that were passed in when running the command. + * All user-inputted string values are redacted, except for some cases where there are set options. + */ + args: Record | undefined; + /** + * The time elapsed between the "wrangler command started" and "wrangler command errored" events + */ + durationMs: number; + durationMinutes: number; + durationSeconds: number; + /** + * Type of error, e.g. UserError, APIError. Does not include stack trace or error message. + */ + errorType: string | undefined; + }; + }; diff --git a/packages/wrangler/src/package-manager.ts b/packages/wrangler/src/package-manager.ts index b7fb6e9951f3..9c42493f8caa 100644 --- a/packages/wrangler/src/package-manager.ts +++ b/packages/wrangler/src/package-manager.ts @@ -200,7 +200,7 @@ function supportsPnpm(): Promise { * - [pnpm](https://github.com/pnpm/pnpm/blob/cd4f9341e966eb8b411462b48ff0c0612e0a51a7/packages/plugin-commands-script-runners/src/makeEnv.ts#L14) * - [yarn](https://yarnpkg.com/advanced/lifecycle-scripts#environment-variables) */ -function sniffUserAgent(): "npm" | "pnpm" | "yarn" | undefined { +export function sniffUserAgent(): "npm" | "pnpm" | "yarn" | undefined { const userAgent = env.npm_config_user_agent; if (userAgent === undefined) { return undefined; diff --git a/packages/wrangler/src/pages/build.ts b/packages/wrangler/src/pages/build.ts index 3c75b1ecc7a1..6a1b8e7774c7 100644 --- a/packages/wrangler/src/pages/build.ts +++ b/packages/wrangler/src/pages/build.ts @@ -319,7 +319,7 @@ export const Handler = async (args: PagesBuildArgs) => { } } - await metrics.sendMetricsEvent("build pages functions"); + metrics.sendMetricsEvent("build pages functions"); }; type WorkerBundleArgs = Omit & { diff --git a/packages/wrangler/src/pages/deploy.ts b/packages/wrangler/src/pages/deploy.ts index dcc7dfdea230..a2f061306311 100644 --- a/packages/wrangler/src/pages/deploy.ts +++ b/packages/wrangler/src/pages/deploy.ts @@ -283,7 +283,7 @@ export const Handler = async (args: PagesDeployArgs) => { }); logger.log(`✨ Successfully created the '${projectName}' project.`); - await metrics.sendMetricsEvent("create pages project"); + metrics.sendMetricsEvent("create pages project"); break; } } @@ -460,7 +460,7 @@ ${failureMessage}`, }, }); - await metrics.sendMetricsEvent("create pages deployment"); + metrics.sendMetricsEvent("create pages deployment"); }; type NewOrExistingItem = { diff --git a/packages/wrangler/src/pages/deployment-tails.ts b/packages/wrangler/src/pages/deployment-tails.ts index 9e0e9c0cdc57..8b9319612eb9 100644 --- a/packages/wrangler/src/pages/deployment-tails.ts +++ b/packages/wrangler/src/pages/deployment-tails.ts @@ -220,7 +220,7 @@ export async function Handler({ status, }); - await metrics.sendMetricsEvent("begin pages log stream", { + metrics.sendMetricsEvent("begin pages log stream", { sendMetrics: config.send_metrics, }); @@ -242,7 +242,7 @@ export async function Handler({ tail.terminate(); await deleteTail(); - await metrics.sendMetricsEvent("end pages log stream", { + metrics.sendMetricsEvent("end pages log stream", { sendMetrics: config.send_metrics, }); @@ -271,7 +271,7 @@ export async function Handler({ await setTimeout(100); break; case tail.CLOSED: - await metrics.sendMetricsEvent("end log stream", { + metrics.sendMetricsEvent("end log stream", { sendMetrics: config.send_metrics, }); throw new Error( diff --git a/packages/wrangler/src/pages/deployments.ts b/packages/wrangler/src/pages/deployments.ts index 6547d8ae1970..428806ac9e4c 100644 --- a/packages/wrangler/src/pages/deployments.ts +++ b/packages/wrangler/src/pages/deployments.ts @@ -87,5 +87,5 @@ export async function ListHandler({ projectName, environment }: ListArgs) { }); logger.table(data); - await metrics.sendMetricsEvent("list pages deployments"); + metrics.sendMetricsEvent("list pages deployments"); } diff --git a/packages/wrangler/src/pages/dev.ts b/packages/wrangler/src/pages/dev.ts index 8b499357ba58..1053cd473115 100644 --- a/packages/wrangler/src/pages/dev.ts +++ b/packages/wrangler/src/pages/dev.ts @@ -677,7 +677,7 @@ export const Handler = async (args: PagesDevArguments) => { watcher.add(currentBundleDependencies); watchedBundleDependencies = [...currentBundleDependencies]; - await metrics.sendMetricsEvent("build pages functions"); + metrics.sendMetricsEvent("build pages functions"); }; /* @@ -925,7 +925,7 @@ export const Handler = async (args: PagesDevArguments) => { enableIpc: true, }, }); - await metrics.sendMetricsEvent("run pages dev"); + metrics.sendMetricsEvent("run pages dev"); CLEANUP_CALLBACKS.push(stop); diff --git a/packages/wrangler/src/pages/projects.ts b/packages/wrangler/src/pages/projects.ts index a76dd8cd1e79..e6b0fdafadd0 100644 --- a/packages/wrangler/src/pages/projects.ts +++ b/packages/wrangler/src/pages/projects.ts @@ -43,7 +43,7 @@ export async function ListHandler() { }); logger.table(data); - await metrics.sendMetricsEvent("list pages projects"); + metrics.sendMetricsEvent("list pages projects"); } export const listProjects = async ({ @@ -182,7 +182,7 @@ export async function CreateHandler({ logger.log( `To deploy a folder of assets, run 'wrangler pages deploy [directory]'.` ); - await metrics.sendMetricsEvent("create pages project"); + metrics.sendMetricsEvent("create pages project"); } export function DeleteOptions(yargs: CommonYargsArgv) { diff --git a/packages/wrangler/src/pages/secret/index.ts b/packages/wrangler/src/pages/secret/index.ts index 17e06329290c..d8d42c105cf7 100644 --- a/packages/wrangler/src/pages/secret/index.ts +++ b/packages/wrangler/src/pages/secret/index.ts @@ -158,7 +158,7 @@ export const secret = (secretYargs: CommonYargsArgv, subHelp: SubHelp) => { } ); - await metrics.sendMetricsEvent("create pages encrypted variable", { + metrics.sendMetricsEvent("create pages encrypted variable", { sendMetrics: config?.send_metrics, }); @@ -311,7 +311,7 @@ export const secret = (secretYargs: CommonYargsArgv, subHelp: SubHelp) => { }), } ); - await metrics.sendMetricsEvent("delete pages encrypted variable", { + metrics.sendMetricsEvent("delete pages encrypted variable", { sendMetrics: config?.send_metrics, }); logger.log(`✨ Success! Deleted secret ${args.key}`); @@ -352,7 +352,7 @@ export const secret = (secretYargs: CommonYargsArgv, subHelp: SubHelp) => { logger.log(message); - await metrics.sendMetricsEvent("list pages encrypted variables", { + metrics.sendMetricsEvent("list pages encrypted variables", { sendMetrics: config?.send_metrics, }); } diff --git a/packages/wrangler/src/pipelines/index.ts b/packages/wrangler/src/pipelines/index.ts index 7c6bbf6e537a..073fec47b886 100644 --- a/packages/wrangler/src/pipelines/index.ts +++ b/packages/wrangler/src/pipelines/index.ts @@ -284,7 +284,7 @@ export function pipelines(pipelineYargs: CommonYargsArgv) { logger.log(`🌀 Creating pipeline named "${name}"`); const pipeline = await createPipeline(accountId, pipelineConfig); - await metrics.sendMetricsEvent("create pipeline", { + metrics.sendMetricsEvent("create pipeline", { sendMetrics: config.send_metrics, }); @@ -307,7 +307,7 @@ export function pipelines(pipelineYargs: CommonYargsArgv) { // TODO: we should show bindings & transforms if they exist for given ids const list = await listPipelines(accountId); - await metrics.sendMetricsEvent("list pipelines", { + metrics.sendMetricsEvent("list pipelines", { sendMetrics: config.send_metrics, }); @@ -340,7 +340,7 @@ export function pipelines(pipelineYargs: CommonYargsArgv) { logger.log(`Retrieving config for pipeline "${name}".`); const pipeline = await getPipeline(accountId, name); - await metrics.sendMetricsEvent("show pipeline", { + metrics.sendMetricsEvent("show pipeline", { sendMetrics: config.send_metrics, }); @@ -473,7 +473,7 @@ export function pipelines(pipelineYargs: CommonYargsArgv) { logger.log(`🌀 Updating pipeline "${name}"`); const pipeline = await updatePipeline(accountId, name, pipelineConfig); - await metrics.sendMetricsEvent("update pipeline", { + metrics.sendMetricsEvent("update pipeline", { sendMetrics: config.send_metrics, }); @@ -503,7 +503,7 @@ export function pipelines(pipelineYargs: CommonYargsArgv) { logger.log(`Deleting pipeline ${name}.`); await deletePipeline(accountId, name); logger.log(`Deleted pipeline ${name}.`); - await metrics.sendMetricsEvent("delete pipeline", { + metrics.sendMetricsEvent("delete pipeline", { sendMetrics: config.send_metrics, }); } diff --git a/packages/wrangler/src/pubsub/pubsub-commands.ts b/packages/wrangler/src/pubsub/pubsub-commands.ts index 8913a639f7e2..2b851fa56ac8 100644 --- a/packages/wrangler/src/pubsub/pubsub-commands.ts +++ b/packages/wrangler/src/pubsub/pubsub-commands.ts @@ -51,7 +51,7 @@ export function pubSubCommands( logger.log(`Creating Pub/Sub Namespace ${args.name}...`); await pubsub.createPubSubNamespace(accountId, namespace); logger.log(`Success! Created Pub/Sub Namespace ${args.name}`); - await metrics.sendMetricsEvent("create pubsub namespace", { + metrics.sendMetricsEvent("create pubsub namespace", { sendMetrics: config.send_metrics, }); } @@ -67,7 +67,7 @@ export function pubSubCommands( const accountId = await requireAuth(config); logger.log(await pubsub.listPubSubNamespaces(accountId)); - await metrics.sendMetricsEvent("list pubsub namespaces", { + metrics.sendMetricsEvent("list pubsub namespaces", { sendMetrics: config.send_metrics, }); } @@ -96,7 +96,7 @@ export function pubSubCommands( logger.log(`Deleting namespace ${args.name}...`); await pubsub.deletePubSubNamespace(accountId, args.name); logger.log(`Deleted namespace ${args.name}.`); - await metrics.sendMetricsEvent("delete pubsub namespace", { + metrics.sendMetricsEvent("delete pubsub namespace", { sendMetrics: config.send_metrics, }); } @@ -121,7 +121,7 @@ export function pubSubCommands( logger.log( await pubsub.describePubSubNamespace(accountId, args.name) ); - await metrics.sendMetricsEvent("view pubsub namespace", { + metrics.sendMetricsEvent("view pubsub namespace", { sendMetrics: config.send_metrics, }); } @@ -191,7 +191,7 @@ export function pubSubCommands( logger.log( await pubsub.createPubSubBroker(accountId, args.namespace, broker) ); - await metrics.sendMetricsEvent("create pubsub broker", { + metrics.sendMetricsEvent("create pubsub broker", { sendMetrics: config.send_metrics, }); } @@ -263,7 +263,7 @@ export function pubSubCommands( ) ); logger.log(`Successfully updated Pub/Sub Broker ${args.name}`); - await metrics.sendMetricsEvent("update pubsub broker", { + metrics.sendMetricsEvent("update pubsub broker", { sendMetrics: config.send_metrics, }); } @@ -287,7 +287,7 @@ export function pubSubCommands( const accountId = await requireAuth(config); logger.log(await pubsub.listPubSubBrokers(accountId, args.namespace)); - await metrics.sendMetricsEvent("list pubsub brokers", { + metrics.sendMetricsEvent("list pubsub brokers", { sendMetrics: config.send_metrics, }); } @@ -328,7 +328,7 @@ export function pubSubCommands( args.name ); logger.log(`Deleted Pub/Sub Broker ${args.name}.`); - await metrics.sendMetricsEvent("delete pubsub broker", { + metrics.sendMetricsEvent("delete pubsub broker", { sendMetrics: config.send_metrics, }); } @@ -363,7 +363,7 @@ export function pubSubCommands( args.name ) ); - await metrics.sendMetricsEvent("view pubsub broker", { + metrics.sendMetricsEvent("view pubsub broker", { sendMetrics: config.send_metrics, }); } @@ -441,7 +441,7 @@ export function pubSubCommands( parsedExpiration ) ); - await metrics.sendMetricsEvent("issue pubsub broker credentials", { + metrics.sendMetricsEvent("issue pubsub broker credentials", { sendMetrics: config.send_metrics, }); } @@ -489,7 +489,7 @@ export function pubSubCommands( ); logger.log(`Revoked ${args.jti.length} credential(s).`); - await metrics.sendMetricsEvent("revoke pubsub broker credentials", { + metrics.sendMetricsEvent("revoke pubsub broker credentials", { sendMetrics: config.send_metrics, }); } @@ -536,7 +536,7 @@ export function pubSubCommands( ); logger.log(`Unrevoked ${numTokens} credential(s)`); - await metrics.sendMetricsEvent("unrevoke pubsub broker credentials", { + metrics.sendMetricsEvent("unrevoke pubsub broker credentials", { sendMetrics: config.send_metrics, }); } @@ -572,12 +572,9 @@ export function pubSubCommands( args.name ) ); - await metrics.sendMetricsEvent( - "list pubsub broker revoked credentials", - { - sendMetrics: config.send_metrics, - } - ); + metrics.sendMetricsEvent("list pubsub broker revoked credentials", { + sendMetrics: config.send_metrics, + }); } ); @@ -610,7 +607,7 @@ export function pubSubCommands( args.name ) ); - await metrics.sendMetricsEvent("list pubsub broker public-keys", { + metrics.sendMetricsEvent("list pubsub broker public-keys", { sendMetrics: config.send_metrics, }); } diff --git a/packages/wrangler/src/r2/bucket.ts b/packages/wrangler/src/r2/bucket.ts index 595476509a6c..f307f5b9aa7c 100644 --- a/packages/wrangler/src/r2/bucket.ts +++ b/packages/wrangler/src/r2/bucket.ts @@ -92,7 +92,7 @@ export const r2BucketCreateCommand = createCommand({ ${formatConfigSnippet({ r2_buckets: [{ bucket_name: args.name, binding: getValidBindingName(args.name, "r2") }] }, config.configPath)}`); - await metrics.sendMetricsEvent("create r2 bucket", { + metrics.sendMetricsEvent("create r2 bucket", { sendMetrics: config.send_metrics, }); }, @@ -259,7 +259,7 @@ export const r2BucketDeleteCommand = createCommand({ logger.log(`Deleting bucket ${fullBucketName}.`); await deleteR2Bucket(accountId, args.bucket, args.jurisdiction); logger.log(`Deleted bucket ${fullBucketName}.`); - await metrics.sendMetricsEvent("delete r2 bucket", { + metrics.sendMetricsEvent("delete r2 bucket", { sendMetrics: config.send_metrics, }); }, diff --git a/packages/wrangler/src/secret/index.ts b/packages/wrangler/src/secret/index.ts index bb284fb550b3..3035b98b9fa8 100644 --- a/packages/wrangler/src/secret/index.ts +++ b/packages/wrangler/src/secret/index.ts @@ -220,7 +220,7 @@ export const secret = (secretYargs: CommonYargsArgv) => { try { await submitSecret(); - await metrics.sendMetricsEvent("create encrypted variable", { + metrics.sendMetricsEvent("create encrypted variable", { sendMetrics: config.send_metrics, }); } catch (e) { @@ -300,7 +300,7 @@ export const secret = (secretYargs: CommonYargsArgv) => { : `/accounts/${accountId}/workers/services/${scriptName}/environments/${args.env}/secrets`; await fetchResult(`${url}/${args.key}`, { method: "DELETE" }); - await metrics.sendMetricsEvent("delete encrypted variable", { + metrics.sendMetricsEvent("delete encrypted variable", { sendMetrics: config.send_metrics, }); logger.log(`✨ Success! Deleted secret ${args.key}`); @@ -357,7 +357,7 @@ export const secret = (secretYargs: CommonYargsArgv) => { logger.log(JSON.stringify(secrets, null, " ")); } - await metrics.sendMetricsEvent("list encrypted variables", { + metrics.sendMetricsEvent("list encrypted variables", { sendMetrics: config.send_metrics, }); } diff --git a/packages/wrangler/src/tail/index.ts b/packages/wrangler/src/tail/index.ts index ab590673ac3c..65986b76b039 100644 --- a/packages/wrangler/src/tail/index.ts +++ b/packages/wrangler/src/tail/index.ts @@ -99,7 +99,7 @@ export async function tailHandler(args: TailArgs) { "For Pages, please run `wrangler pages deployment tail` instead." ); } - await metrics.sendMetricsEvent("begin log stream", { + metrics.sendMetricsEvent("begin log stream", { sendMetrics: config.send_metrics, }); @@ -173,7 +173,7 @@ export async function tailHandler(args: TailArgs) { await setTimeout(100); break; case tail.CLOSED: - await metrics.sendMetricsEvent("end log stream", { + metrics.sendMetricsEvent("end log stream", { sendMetrics: config.send_metrics, }); throw new Error( @@ -194,7 +194,7 @@ export async function tailHandler(args: TailArgs) { cancelPing(); tail.terminate(); await deleteTail(); - await metrics.sendMetricsEvent("end log stream", { + metrics.sendMetricsEvent("end log stream", { sendMetrics: config.send_metrics, }); } diff --git a/packages/wrangler/src/triggers/index.ts b/packages/wrangler/src/triggers/index.ts index 1439b411ef4f..1a65f770e5a8 100644 --- a/packages/wrangler/src/triggers/index.ts +++ b/packages/wrangler/src/triggers/index.ts @@ -59,7 +59,7 @@ async function triggersDeployHandler( const config = readConfig(undefined, args); const assetsOptions = getAssetsOptions({ assets: undefined }, config); - await metrics.sendMetricsEvent( + metrics.sendMetricsEvent( "deploy worker triggers", {}, { diff --git a/packages/wrangler/src/user/commands.ts b/packages/wrangler/src/user/commands.ts index 15cd35f5bc87..e2e33dfcf8a9 100644 --- a/packages/wrangler/src/user/commands.ts +++ b/packages/wrangler/src/user/commands.ts @@ -49,7 +49,7 @@ export const loginCommand = createCommand({ return; } await login({ browser: args.browser }); - await metrics.sendMetricsEvent("login user", { + metrics.sendMetricsEvent("login user", { sendMetrics: config.send_metrics, }); @@ -70,7 +70,7 @@ export const logoutCommand = createCommand({ }, async handler(_, { config }) { await logout(); - await metrics.sendMetricsEvent("logout user", { + metrics.sendMetricsEvent("logout user", { sendMetrics: config.send_metrics, }); }, @@ -94,7 +94,7 @@ export const whoamiCommand = createCommand({ }, async handler(args, { config }) { await whoami(args.account); - await metrics.sendMetricsEvent("view accounts", { + metrics.sendMetricsEvent("view accounts", { sendMetrics: config.send_metrics, }); }, diff --git a/packages/wrangler/src/versions/deploy.ts b/packages/wrangler/src/versions/deploy.ts index 29e80fccc341..a2341c6a9d91 100644 --- a/packages/wrangler/src/versions/deploy.ts +++ b/packages/wrangler/src/versions/deploy.ts @@ -97,7 +97,7 @@ export const versionsDeployCommand = createCommand({ }, positionalArgs: ["version-specs"], handler: async function versionsDeployHandler(args, { config }) { - await metrics.sendMetricsEvent( + metrics.sendMetricsEvent( "deploy worker versions", {}, { diff --git a/packages/wrangler/src/versions/deployments/list.ts b/packages/wrangler/src/versions/deployments/list.ts index f0f5500fb621..9153a08c730f 100644 --- a/packages/wrangler/src/versions/deployments/list.ts +++ b/packages/wrangler/src/versions/deployments/list.ts @@ -43,7 +43,7 @@ export async function versionsDeploymentsListHandler( } const config = getConfig(args); - await metrics.sendMetricsEvent( + metrics.sendMetricsEvent( "list versioned deployments", { json: args.json }, { diff --git a/packages/wrangler/src/versions/deployments/status.ts b/packages/wrangler/src/versions/deployments/status.ts index ef09eb972075..47366cce8a6a 100644 --- a/packages/wrangler/src/versions/deployments/status.ts +++ b/packages/wrangler/src/versions/deployments/status.ts @@ -43,7 +43,7 @@ export async function versionsDeploymentsStatusHandler( } const config = getConfig(args); - await metrics.sendMetricsEvent( + metrics.sendMetricsEvent( "view latest versioned deployment", {}, { diff --git a/packages/wrangler/src/versions/list.ts b/packages/wrangler/src/versions/list.ts index 4957c75dcbd1..77c18c310248 100644 --- a/packages/wrangler/src/versions/list.ts +++ b/packages/wrangler/src/versions/list.ts @@ -28,7 +28,7 @@ export const versionsListCommand = createCommand({ }, }, handler: async function versionsSecretListHandler(args, { config }) { - await metrics.sendMetricsEvent( + metrics.sendMetricsEvent( "list worker versions", { json: args.json }, { diff --git a/packages/wrangler/src/versions/view.ts b/packages/wrangler/src/versions/view.ts index 16a219885624..2b6fa8bc2f77 100644 --- a/packages/wrangler/src/versions/view.ts +++ b/packages/wrangler/src/versions/view.ts @@ -41,7 +41,7 @@ export const versionsViewCommand = createCommand({ await printWranglerBanner(); } - await metrics.sendMetricsEvent( + metrics.sendMetricsEvent( "view worker version", {}, { diff --git a/packages/wrangler/telemetry.md b/packages/wrangler/telemetry.md new file mode 100644 index 000000000000..d4dff10386b3 --- /dev/null +++ b/packages/wrangler/telemetry.md @@ -0,0 +1,73 @@ +# Wrangler CLI Telemetry + +Cloudflare gathers non-user identifying telemetry data about usage of [Wrangler](https://www.npmjs.com/package/wrangler), the command-line interface for building and deploying Workers and Pages applications. + +You can [opt out of sharing telemetry data](#how-can-i-configure-wrangler-telemetry) at any time. + +## Why are we collecting telemetry data? + +Telemetry in Wrangler allows us to better identify bugs and gain visibility on usage of features across all users. It also helps us to make data-informed decisions like adding, improving or removing features. We monitor and analyze this data to ensure Wrangler’s consistent growth, stability, usability and developer experience. For instance, if certain errors are hit more frequently, those bug fixes will be prioritized in future releases. + +## What telemetry data is Cloudflare collecting? + +- What command is being run (e.g. `wrangler deploy`, `wrangler dev`) +- Anonymized arguments and flags given to Wrangler (e.g. `wrangler deploy ./src/index.ts --dry-run=true --outdir=dist` would be sent as `wrangler deploy REDACTED --dry-run=true --outdir=REDACTED`) +- The version of the Wrangler client that is sending the event +- The package manager that the Wrangler client is using. (e.g. npm, yarn) +- The major version of Node.js that the Wrangler client is running on +- Whether this is the first time the user has used the Wrangler client +- The format of the Wrangler configuration file (e.g. `toml`, `jsonc`) +- Total session duration of the command run (e.g. 3 seconds, etc.) +- Whether the Wrangler client is running in CI or in an interactive instance +- Error _type_, if one occurs (e.g. `APIError` or `UserError`) +- General machine information such as OS and OS Version + +Cloudflare will receive the IP address associated with your machine and such information is handled in accordance with Cloudflare’s [Privacy Policy](https://www.cloudflare.com/privacypolicy/). + +**Note**: This list is regularly audited to ensure its accuracy. + +## What happens with sensitive data? + +Cloudflare takes your privacy seriously and does not collect any sensitive information including: usernames, raw error logs, stack traces, file names/paths, content of files, and environment variables. Data is never shared with third parties. + +## How can I view analytics data? + +To view what is being collected while using Wrangler, provide the following environment variable in your command: + +`WRANGLER_LOG=debug` + +e.g. + +```sh +WRANGLER_LOG=debug npx wrangler deploy +``` + +Telemetry source code can be viewed at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/src/metrics. It is run in the background and will not delay project execution. As a result, when necessary (e.g. no internet connection), it will fail quickly and quietly. + +## How can I configure Wrangler telemetry? + +If you would like to disable telemetry, you can run: + +```sh +npx wrangler telemetry disable +``` + +You may also configure telemetry on a per project basis by adding the following field to your project’s wrangler.toml: + +`send_metrics=false` + +Alternatively, you may set an environment variable to disable telemetry. + +`WRANGLER_SEND_METRICS=false` + +If you would like to re-enable telemetry globally, you can run: + +```sh +npx wrangler telemetry enable +``` + +If you would like to check the status of Wrangler telemetry, you can run: + +```sh +npx wrangler telemetry status +```