diff --git a/.changeset/eighty-guests-join.md b/.changeset/eighty-guests-join.md new file mode 100644 index 000000000000..3663268183ae --- /dev/null +++ b/.changeset/eighty-guests-join.md @@ -0,0 +1,21 @@ +--- +"create-cloudflare": minor +--- + +feat: telemetry collection + +Cloudflare will collect telemetry about your usage of `create-cloudflare` to improve the experience. + +If you would like to disable telemetry, you can run: + +```sh +npm create cloudflare telemetry disable +``` + +Alternatively, you can set an environment variable: + +```sh +export CREATE_CLOUDFLARE_TELEMETRY_DISABLED=1 +``` + +Read more about our data policy at https://github.com/cloudflare/workers-sdk/blob/main/packages/create-cloudflare/telemetry.md. diff --git a/packages/cli/error.ts b/packages/cli/error.ts new file mode 100644 index 000000000000..d405afc03d17 --- /dev/null +++ b/packages/cli/error.ts @@ -0,0 +1,11 @@ +/** + * Error thrown when an operation is cancelled + */ +export class CancelError extends Error { + constructor( + message?: string, + readonly signal?: NodeJS.Signals + ) { + super(message); + } +} diff --git a/packages/cli/interactive.ts b/packages/cli/interactive.ts index a2a48889e151..8af97023e16b 100644 --- a/packages/cli/interactive.ts +++ b/packages/cli/interactive.ts @@ -7,6 +7,7 @@ import { } from "@clack/core"; import { createLogUpdate } from "log-update"; import { blue, bold, brandColor, dim, gray, white } from "./colors"; +import { CancelError } from "./error"; import SelectRefreshablePrompt from "./select-list"; import { stdout } from "./streams"; import { @@ -56,6 +57,8 @@ export type BasePromptConfig = { validate?: (value: Arg) => string | void; // Override some/all renderers (can be used for custom renderers before hoisting back into shared code) renderers?: Partial>; + // Whether to throw an error if the prompt is crashed or cancelled + throwOnError?: boolean; }; export type TextPromptConfig = BasePromptConfig & { @@ -108,7 +111,11 @@ function acceptDefault( ): T { const error = promptConfig.validate?.(initialValue as Arg); if (error) { - crash(error); + if (promptConfig.throwOnError) { + throw new Error(error); + } else { + crash(error); + } } const lines = renderers.submit({ value: initialValue as Arg }); @@ -226,8 +233,12 @@ export const inputPrompt = async ( const input = (await prompt.prompt()) as T; if (isCancel(input)) { - cancel("Operation cancelled."); - process.exit(0); + if (promptConfig.throwOnError) { + throw new CancelError("Operation cancelled"); + } else { + cancel("Operation cancelled"); + process.exit(0); + } } return input; @@ -258,11 +269,6 @@ const renderSubmit = (config: PromptConfig, value: string) => { return [`${leftT} ${question}`, content, `${grayBar}`]; }; -const handleCancel = () => { - cancel("Operation cancelled."); - process.exit(0); -}; - export const getRenderers = (config: PromptConfig) => { switch (config.type) { case "select": @@ -283,6 +289,11 @@ const getTextRenderers = (config: TextPromptConfig) => { const helpText = config.helpText ?? ""; const format = config.format ?? ((val: Arg) => String(val)); const defaultValue = config.defaultValue?.toString() ?? ""; + const activeRenderer = ({ value }: { value: Arg }) => [ + `${blCorner} ${bold(question)} ${dim(helpText)}`, + `${space(2)}${format(value || dim(defaultValue))}`, + ``, // extra line for readability + ]; return { initial: () => [ @@ -290,11 +301,7 @@ const getTextRenderers = (config: TextPromptConfig) => { `${space(2)}${gray(format(defaultValue))}`, ``, // extra line for readability ], - active: ({ value }: { value: Arg }) => [ - `${blCorner} ${bold(question)} ${dim(helpText)}`, - `${space(2)}${format(value || dim(defaultValue))}`, - ``, // extra line for readability - ], + active: activeRenderer, error: ({ value, error }: { value: Arg; error: string }) => [ `${leftT} ${status.error} ${dim(error)}`, `${grayBar}`, @@ -304,7 +311,7 @@ const getTextRenderers = (config: TextPromptConfig) => { ], submit: ({ value }: { value: Arg }) => renderSubmit(config, format(value ?? "")), - cancel: handleCancel, + cancel: activeRenderer, }; }; @@ -435,7 +442,7 @@ const getSelectRenderers = ( options.find((o) => o.value === value)?.label as string ); }, - cancel: handleCancel, + cancel: defaultRenderer, }; }; @@ -559,7 +566,7 @@ const getSelectListRenderers = (config: ListPromptConfig) => { options.find((o) => o.value === value)?.value as string ); }, - cancel: handleCancel, + cancel: defaultRenderer, }; }; @@ -586,7 +593,7 @@ const getConfirmRenderers = (config: ConfirmPromptConfig) => { error: defaultRenderer, submit: ({ value }: { value: Arg }) => renderSubmit(config, value ? "yes" : "no"), - cancel: handleCancel, + cancel: defaultRenderer, }; }; diff --git a/packages/create-cloudflare/package.json b/packages/create-cloudflare/package.json index 0ad0a8cae2e9..d58fe5b36727 100644 --- a/packages/create-cloudflare/package.json +++ b/packages/create-cloudflare/package.json @@ -84,6 +84,7 @@ "which-pm-runs": "^1.1.0", "wrangler": "workspace:*", "wrap-ansi": "^9.0.0", + "xdg-app-paths": "^8.3.0", "yargs": "^17.7.2" }, "engines": { diff --git a/packages/create-cloudflare/scripts/build.ts b/packages/create-cloudflare/scripts/build.ts index 9cc9c45fb0f6..b08aebbc67bd 100644 --- a/packages/create-cloudflare/scripts/build.ts +++ b/packages/create-cloudflare/scripts/build.ts @@ -14,6 +14,11 @@ const run = async () => { // This is required to support jsonc-parser. See https://github.com/microsoft/node-jsonc-parser/issues/57 mainFields: ["module", "main"], format: "cjs", + define: { + "process.env.SPARROW_SOURCE_KEY": JSON.stringify( + process.env.SPARROW_SOURCE_KEY ?? "", + ), + }, }; const runBuild = async () => { diff --git a/packages/create-cloudflare/src/__tests__/deploy.test.ts b/packages/create-cloudflare/src/__tests__/deploy.test.ts index f4abea2ec485..5af57ed1e653 100644 --- a/packages/create-cloudflare/src/__tests__/deploy.test.ts +++ b/packages/create-cloudflare/src/__tests__/deploy.test.ts @@ -1,4 +1,3 @@ -import { crash } from "@cloudflare/cli"; import { inputPrompt } from "@cloudflare/cli/interactive"; import { mockPackageManager, mockSpinner } from "helpers/__tests__/mocks"; import { runCommand } from "helpers/command"; @@ -12,7 +11,6 @@ vi.mock("helpers/command"); vi.mock("../wrangler/accounts"); vi.mock("@cloudflare/cli/interactive"); vi.mock("which-pm-runs"); -vi.mock("@cloudflare/cli"); vi.mock("helpers/files"); const mockInsideGitRepo = (isInside = true) => { @@ -135,7 +133,6 @@ describe("deploy helpers", async () => { vi.mocked(runCommand).mockResolvedValueOnce(deployedUrl); await runDeploy(ctx); - expect(crash).not.toHaveBeenCalled(); expect(runCommand).toHaveBeenCalledWith( ["npm", "run", "deploy", "--", "--commit-message", `"${commitMsg}"`], expect.any(Object), @@ -146,8 +143,9 @@ describe("deploy helpers", async () => { test("no account in ctx", async () => { const ctx = createTestContext(); ctx.account = undefined; - await runDeploy(ctx); - expect(crash).toHaveBeenCalledWith("Failed to read Cloudflare account."); + await expect(() => runDeploy(ctx)).rejects.toThrow( + "Failed to read Cloudflare account.", + ); }); test("Failed deployment", async () => { @@ -158,8 +156,9 @@ describe("deploy helpers", async () => { mockInsideGitRepo(false); vi.mocked(runCommand).mockResolvedValueOnce(""); - await runDeploy(ctx); - expect(crash).toHaveBeenCalledWith("Failed to find deployment url."); + await expect(() => runDeploy(ctx)).rejects.toThrow( + "Failed to find deployment url.", + ); }); }); }); diff --git a/packages/create-cloudflare/src/__tests__/dialog.test.ts b/packages/create-cloudflare/src/__tests__/dialog.test.ts index 835de7522fe7..b021c890b1cd 100644 --- a/packages/create-cloudflare/src/__tests__/dialog.test.ts +++ b/packages/create-cloudflare/src/__tests__/dialog.test.ts @@ -1,19 +1,45 @@ -import { afterEach, beforeAll, describe, expect, test } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest"; import { collectCLIOutput, normalizeOutput } from "../../../cli/test-util"; import { printSummary, printWelcomeMessage } from "../dialog"; import type { C3Context } from "types"; describe("dialog helpers", () => { const std = collectCLIOutput(); + const originalColumns = process.stdout.columns; - test("printWelcomeMessage", () => { - printWelcomeMessage("0.0.0"); + beforeAll(() => { + process.stdout.columns = 60; + }); + + afterAll(() => { + process.stdout.columns = originalColumns; + }); + + test("printWelcomeMessage with telemetry disabled", () => { + printWelcomeMessage("0.0.0", false); + + expect(normalizeOutput(std.out)).toMatchInlineSnapshot(` + "──────────────────────────────────────────────────────────── + 👋 Welcome to create-cloudflare v0.0.0! + 🧡 Let's get started. + ──────────────────────────────────────────────────────────── + + " + `); + }); + + test("printWelcomeMessage with telemetry enabled", () => { + printWelcomeMessage("0.0.0", true); expect(normalizeOutput(std.out)).toMatchInlineSnapshot(` - " ╭──────────────────────────────────────────────────────────────╮ - │ 👋 Welcome to create-cloudflare v0.0.0! │ - │ 🧡 Let's get started. │ - ╰──────────────────────────────────────────────────────────────╯ + "──────────────────────────────────────────────────────────── + 👋 Welcome to create-cloudflare v0.0.0! + 🧡 Let's get started. + 📊 Cloudflare collects telemetry about your usage of Create-Cloudflare. + + Learn more at: https://github.com/cloudflare/workers-sdk/blob/main/packages/create-cloudflare/telemetry.md + ──────────────────────────────────────────────────────────── + " `); }); @@ -55,23 +81,24 @@ describe("dialog helpers", () => { await printSummary(ctx); expect(normalizeOutput(std.out)).toMatchInlineSnapshot(` - " ╭───────────────────────────────────────────────────────────────────────────────────────╮ - │ 🎉 SUCCESS Application deployed successfully! │ - │ │ - │ 🔍 View Project │ - │ Visit: https://example.test.workers.dev │ - │ Dash: https://dash.cloudflare.com/?to=/:account/workers/services/view/test-project │ - │ │ - │ 💻 Continue Developing │ - │ Start dev server: pnpm run start │ - │ Deploy again: pnpm run deploy │ - │ │ - │ 📖 Explore Documentation │ - │ https://developers.cloudflare.com/workers │ - │ │ - │ 💬 Join our Community │ - │ https://discord.cloudflare.com │ - ╰───────────────────────────────────────────────────────────────────────────────────────╯ + "──────────────────────────────────────────────────────────── + 🎉 SUCCESS Application deployed successfully! + + 🔍 View Project + Visit: https://example.test.workers.dev + Dash: https://dash.cloudflare.com/?to=/:account/workers/services/view/test-project + + 💻 Continue Developing + Start dev server: pnpm run start + Deploy again: pnpm run deploy + + 📖 Explore Documentation + https://developers.cloudflare.com/workers + + 💬 Join our Community + https://discord.cloudflare.com + ──────────────────────────────────────────────────────────── + " `); }); @@ -89,47 +116,21 @@ describe("dialog helpers", () => { }); expect(normalizeOutput(std.out)).toMatchInlineSnapshot(` - " ╭──────────────────────────────────────────────────────────────╮ - │ 🎉 SUCCESS Application created successfully! │ - │ │ - │ 💻 Continue Developing │ - │ Change directories: cd ../example │ - │ Start dev server: pnpm run start │ - │ Deploy: pnpm run deploy │ - │ │ - │ 📖 Explore Documentation │ - │ https://developers.cloudflare.com/pages │ - │ │ - │ 💬 Join our Community │ - │ https://discord.cloudflare.com │ - ╰──────────────────────────────────────────────────────────────╯ - " - `); - }); + "──────────────────────────────────────────────────────────── + 🎉 SUCCESS Application created successfully! - test("with lines truncated", async () => { - process.stdout.columns = 40; + 💻 Continue Developing + Change directories: cd ../example + Start dev server: pnpm run start + Deploy: pnpm run deploy - await printSummary(ctx); + 📖 Explore Documentation + https://developers.cloudflare.com/pages + + 💬 Join our Community + https://discord.cloudflare.com + ──────────────────────────────────────────────────────────── - expect(normalizeOutput(std.out)).toMatchInlineSnapshot(` - " ╭─────────────────────────────────────╮ - │ 🎉 SUCCESS Application deploye... │ - │ │ - │ 🔍 View Project │ - │ Visit: https://example.test.w... │ - │ Dash: https://dash.cloudflare... │ - │ │ - │ 💻 Continue Developing │ - │ Start dev server: pnpm run start │ - │ Deploy again: pnpm run deploy │ - │ │ - │ 📖 Explore Documentation │ - │ https://developers.cloudflare... │ - │ │ - │ 💬 Join our Community │ - │ https://discord.cloudflare.com │ - ╰─────────────────────────────────────╯ " `); }); diff --git a/packages/create-cloudflare/src/__tests__/git.test.ts b/packages/create-cloudflare/src/__tests__/git.test.ts index e7c07bd656ac..590baa8152dc 100644 --- a/packages/create-cloudflare/src/__tests__/git.test.ts +++ b/packages/create-cloudflare/src/__tests__/git.test.ts @@ -1,6 +1,6 @@ import { updateStatus } from "@cloudflare/cli"; -import { processArgument } from "@cloudflare/cli/args"; import { mockSpinner } from "helpers/__tests__/mocks"; +import { processArgument } from "helpers/args"; import { runCommand } from "helpers/command"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { @@ -15,7 +15,7 @@ import { import { createTestContext } from "./helpers"; vi.mock("helpers/command"); -vi.mock("@cloudflare/cli/args"); +vi.mock("helpers/args"); vi.mock("@cloudflare/cli/interactive"); vi.mock("@cloudflare/cli"); diff --git a/packages/create-cloudflare/src/__tests__/metrics.test.ts b/packages/create-cloudflare/src/__tests__/metrics.test.ts new file mode 100644 index 000000000000..d60edce5a7c6 --- /dev/null +++ b/packages/create-cloudflare/src/__tests__/metrics.test.ts @@ -0,0 +1,553 @@ +import { CancelError } from "@cloudflare/cli/error"; +import { detectPackageManager } from "helpers/packageManagers"; +import { hasSparrowSourceKey, sendEvent } from "helpers/sparrow"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { collectCLIOutput, normalizeOutput } from "../../../cli/test-util"; +import { version as c3Version } from "../../package.json"; +import { + getDeviceId, + readMetricsConfig, + writeMetricsConfig, +} from "../helpers/metrics-config"; +import { + createReporter, + getPlatform, + promiseWithResolvers, + runTelemetryCommand, +} from "../metrics"; + +vi.mock("helpers/metrics-config"); +vi.mock("helpers/sparrow"); + +describe("createReporter", () => { + const now = 987654321; + const deviceId = "test-device-id"; + const platform = getPlatform(); + const packageManager = detectPackageManager().name; + + beforeEach(() => { + vi.useFakeTimers({ now }); + vi.mocked(readMetricsConfig).mockReturnValue({ + c3permission: { + enabled: true, + date: new Date(), + }, + }); + vi.mocked(getDeviceId).mockReturnValue(deviceId); + vi.mocked(hasSparrowSourceKey).mockReturnValue(true); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + test("sends started and completed event to sparrow if the promise resolves", async () => { + const deferred = promiseWithResolvers(); + const reporter = createReporter(); + const operation = reporter.collectAsyncMetrics({ + eventPrefix: "c3 session", + props: { + args: { + projectName: "app", + }, + }, + promise: () => deferred.promise, + }); + + expect(sendEvent).toBeCalledWith({ + event: "c3 session started", + deviceId, + timestamp: now, + properties: { + c3Version, + platform, + packageManager, + isFirstUsage: false, + amplitude_session_id: now, + amplitude_event_id: 0, + args: { + projectName: "app", + }, + }, + }); + expect(sendEvent).toBeCalledTimes(1); + + deferred.resolve("test result"); + + vi.advanceTimersByTime(1234); + + await expect(operation).resolves.toBe("test result"); + + expect(sendEvent).toBeCalledWith({ + event: "c3 session completed", + deviceId, + timestamp: now + 1234, + properties: { + c3Version, + platform, + packageManager, + isFirstUsage: false, + amplitude_session_id: now, + amplitude_event_id: 1, + args: { + projectName: "app", + }, + durationMs: 1234, + durationSeconds: 1234 / 1000, + durationMinutes: 1234 / 1000 / 60, + }, + }); + expect(sendEvent).toBeCalledTimes(2); + }); + + test("sends no event if no sparrow source key", async () => { + vi.mocked(hasSparrowSourceKey).mockReturnValue(false); + + const deferred = promiseWithResolvers(); + const reporter = createReporter(); + const operation = reporter.collectAsyncMetrics({ + eventPrefix: "c3 session", + props: { + args: { + projectName: "app", + }, + }, + promise: () => deferred.promise, + }); + + expect(reporter.isEnabled).toBe(false); + + expect(sendEvent).toBeCalledTimes(0); + + deferred.resolve("test result"); + + await expect(operation).resolves.toBe("test result"); + expect(sendEvent).toBeCalledTimes(0); + }); + + test("sends no event if the c3 permission is disabled", async () => { + vi.mocked(readMetricsConfig).mockReturnValueOnce({ + c3permission: { + enabled: false, + date: new Date(), + }, + }); + + const deferred = promiseWithResolvers(); + const reporter = createReporter(); + const operation = reporter.collectAsyncMetrics({ + eventPrefix: "c3 session", + props: { + args: { + projectName: "app", + }, + }, + promise: () => deferred.promise, + }); + + expect(reporter.isEnabled).toBe(false); + + expect(sendEvent).toBeCalledTimes(0); + + deferred.resolve("test result"); + + await expect(operation).resolves.toBe("test result"); + expect(sendEvent).toBeCalledTimes(0); + }); + + test("sends no event if the CREATE_CLOUDFLARE_TELEMETRY_DISABLED env is set to '1'", async () => { + vi.stubEnv("CREATE_CLOUDFLARE_TELEMETRY_DISABLED", "1"); + + const deferred = promiseWithResolvers(); + const reporter = createReporter(); + const operation = reporter.collectAsyncMetrics({ + eventPrefix: "c3 session", + props: { + args: { + projectName: "app", + }, + }, + promise: () => deferred.promise, + }); + + expect(reporter.isEnabled).toBe(false); + + expect(sendEvent).toBeCalledTimes(0); + + deferred.resolve("test result"); + + await expect(operation).resolves.toBe("test result"); + expect(sendEvent).toBeCalledTimes(0); + }); + + test("sends started and cancelled event to sparrow if the promise reject with a CancelError", async () => { + const deferred = promiseWithResolvers(); + const reporter = createReporter(); + const operation = reporter.collectAsyncMetrics({ + eventPrefix: "c3 session", + props: { + args: { + projectName: "app", + }, + }, + promise: () => deferred.promise, + }); + + expect(sendEvent).toBeCalledWith({ + event: "c3 session started", + deviceId, + timestamp: now, + properties: { + amplitude_session_id: now, + amplitude_event_id: 0, + c3Version, + platform, + packageManager, + isFirstUsage: false, + args: { + projectName: "app", + }, + }, + }); + expect(sendEvent).toBeCalledTimes(1); + + deferred.reject(new CancelError("test cancel")); + vi.advanceTimersByTime(1234); + + await expect(operation).rejects.toThrow(CancelError); + + expect(sendEvent).toBeCalledWith({ + event: "c3 session cancelled", + deviceId, + timestamp: now + 1234, + properties: { + amplitude_session_id: now, + amplitude_event_id: 1, + c3Version, + platform, + packageManager, + isFirstUsage: false, + args: { + projectName: "app", + }, + durationMs: 1234, + durationSeconds: 1234 / 1000, + durationMinutes: 1234 / 1000 / 60, + }, + }); + expect(sendEvent).toBeCalledTimes(2); + }); + + test("sends started and errored event to sparrow if the promise reject with a non CancelError", async () => { + const deferred = promiseWithResolvers(); + const reporter = createReporter(); + const process = reporter.collectAsyncMetrics({ + eventPrefix: "c3 session", + props: { + args: { projectName: "app" }, + }, + promise: () => deferred.promise, + }); + + expect(sendEvent).toBeCalledWith({ + event: "c3 session started", + deviceId, + timestamp: now, + properties: { + amplitude_session_id: now, + amplitude_event_id: 0, + c3Version, + platform, + packageManager, + isFirstUsage: false, + args: { + projectName: "app", + }, + }, + }); + expect(sendEvent).toBeCalledTimes(1); + + deferred.reject(new Error("test error")); + vi.advanceTimersByTime(1234); + + await expect(process).rejects.toThrow(Error); + + expect(sendEvent).toBeCalledWith({ + event: "c3 session errored", + deviceId, + timestamp: now + 1234, + properties: { + amplitude_session_id: now, + amplitude_event_id: 1, + c3Version, + platform, + packageManager, + isFirstUsage: false, + args: { + projectName: "app", + }, + durationMs: 1234, + durationSeconds: 1234 / 1000, + durationMinutes: 1234 / 1000 / 60, + error: { + message: "test error", + stack: expect.any(String), + }, + }, + }); + expect(sendEvent).toBeCalledTimes(2); + }); + + test("sends cancelled event if a SIGINT signal is recieved", async () => { + const deferred = promiseWithResolvers(); + const reporter = createReporter(); + + const run = reporter.collectAsyncMetrics({ + eventPrefix: "c3 session", + props: { + args: { + projectName: "app", + }, + }, + promise: () => deferred.promise, + }); + + expect(sendEvent).toBeCalledWith({ + event: "c3 session started", + deviceId, + timestamp: now, + properties: { + amplitude_session_id: now, + amplitude_event_id: 0, + c3Version, + platform, + packageManager, + isFirstUsage: false, + args: { + projectName: "app", + }, + }, + }); + expect(sendEvent).toBeCalledTimes(1); + + process.emit("SIGINT", "SIGINT"); + vi.advanceTimersByTime(1234); + + await expect(run).rejects.toThrow(CancelError); + + expect(sendEvent).toBeCalledWith({ + event: "c3 session cancelled", + deviceId, + timestamp: now + 1234, + properties: { + amplitude_session_id: now, + amplitude_event_id: 1, + c3Version, + platform, + packageManager, + isFirstUsage: false, + args: { + projectName: "app", + }, + signal: "SIGINT", + durationMs: 1234, + durationSeconds: 1234 / 1000, + durationMinutes: 1234 / 1000 / 60, + }, + }); + expect(sendEvent).toBeCalledTimes(2); + }); + + test("sends cancelled event if a SIGTERM signal is recieved", async () => { + const deferred = promiseWithResolvers(); + const reporter = createReporter(); + const run = reporter.collectAsyncMetrics({ + eventPrefix: "c3 session", + props: { + args: { + projectName: "app", + }, + }, + promise: () => deferred.promise, + }); + + expect(sendEvent).toBeCalledWith({ + event: "c3 session started", + deviceId, + timestamp: now, + properties: { + amplitude_session_id: now, + amplitude_event_id: 0, + c3Version, + platform, + packageManager, + isFirstUsage: false, + args: { + projectName: "app", + }, + }, + }); + expect(sendEvent).toBeCalledTimes(1); + + process.emit("SIGTERM", "SIGTERM"); + vi.advanceTimersByTime(1234); + + await expect(run).rejects.toThrow(CancelError); + + expect(sendEvent).toBeCalledWith({ + event: "c3 session cancelled", + deviceId, + timestamp: now + 1234, + properties: { + amplitude_session_id: now, + amplitude_event_id: 1, + c3Version, + platform, + packageManager, + isFirstUsage: false, + args: { + projectName: "app", + }, + signal: "SIGTERM", + durationMs: 1234, + durationSeconds: 1234 / 1000, + durationMinutes: 1234 / 1000 / 60, + }, + }); + expect(sendEvent).toBeCalledTimes(2); + }); +}); + +describe("runTelemetryCommand", () => { + const std = collectCLIOutput(); + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("run telemetry status when c3permission is disabled", async () => { + vi.mocked(readMetricsConfig).mockReturnValueOnce({ + c3permission: { + enabled: false, + date: new Date(), + }, + }); + + runTelemetryCommand("status"); + + expect(normalizeOutput(std.out)).toMatchInlineSnapshot(` + "Status: Disabled + + " + `); + }); + + test("run telemetry status when c3permission is enabled", async () => { + vi.mocked(readMetricsConfig).mockReturnValueOnce({ + c3permission: { + enabled: true, + date: new Date(), + }, + }); + + runTelemetryCommand("status"); + + expect(normalizeOutput(std.out)).toMatchInlineSnapshot(` + "Status: Enabled + + " + `); + }); + + test("run telemetry enable when c3permission is disabled", async () => { + vi.mocked(readMetricsConfig).mockReturnValueOnce({ + c3permission: { + enabled: false, + date: new Date(), + }, + }); + + runTelemetryCommand("enable"); + + expect(writeMetricsConfig).toBeCalledWith({ + c3permission: { + enabled: true, + date: new Date(), + }, + }); + expect(normalizeOutput(std.out)).toMatchInlineSnapshot(` + "Status: Enabled + + Create-Cloudflare is now collecting telemetry about your usage. Thank you for helping us improve the experience! + " + `); + }); + + test("run telemetry enable when c3permission is enabled", async () => { + vi.mocked(readMetricsConfig).mockReturnValueOnce({ + c3permission: { + enabled: true, + date: new Date(), + }, + }); + + runTelemetryCommand("enable"); + + expect(writeMetricsConfig).not.toBeCalled(); + expect(normalizeOutput(std.out)).toMatchInlineSnapshot(` + "Status: Enabled + + Create-Cloudflare is now collecting telemetry about your usage. Thank you for helping us improve the experience! + " + `); + }); + + test("run telemetry disable when c3permission is enabled", async () => { + vi.mocked(readMetricsConfig).mockReturnValueOnce({ + c3permission: { + enabled: true, + date: new Date(), + }, + }); + + runTelemetryCommand("disable"); + + expect(writeMetricsConfig).toBeCalledWith({ + c3permission: { + enabled: false, + date: new Date(), + }, + }); + expect(normalizeOutput(std.out)).toMatchInlineSnapshot(` + "Status: Disabled + + Create-Cloudflare is no longer collecting telemetry + " + `); + }); + + test("run telemetry disable when c3permission is disabled", async () => { + vi.mocked(readMetricsConfig).mockReturnValueOnce({ + c3permission: { + enabled: false, + date: new Date(), + }, + }); + + runTelemetryCommand("disable"); + + expect(writeMetricsConfig).not.toBeCalled(); + expect(normalizeOutput(std.out)).toMatchInlineSnapshot(` + "Status: Disabled + + Create-Cloudflare is no longer collecting telemetry + " + `); + }); +}); diff --git a/packages/create-cloudflare/src/__tests__/templates.test.ts b/packages/create-cloudflare/src/__tests__/templates.test.ts index ca7a0a56d092..1aa03f57d223 100644 --- a/packages/create-cloudflare/src/__tests__/templates.test.ts +++ b/packages/create-cloudflare/src/__tests__/templates.test.ts @@ -1,5 +1,4 @@ import { existsSync, statSync } from "fs"; -import { crash } from "@cloudflare/cli"; import { spinner } from "@cloudflare/cli/interactive"; import degit from "degit"; import { mockSpinner } from "helpers/__tests__/mocks"; @@ -21,7 +20,6 @@ import type { C3Args, C3Context } from "types"; vi.mock("degit"); vi.mock("fs"); vi.mock("helpers/files"); -vi.mock("@cloudflare/cli"); vi.mock("@cloudflare/cli/interactive"); beforeEach(() => { @@ -305,22 +303,17 @@ describe("deriveCorrelatedArgs", () => { }); test("should crash if both the lang and ts arguments are specified", () => { - let args: Partial = { - lang: "ts", - }; - - deriveCorrelatedArgs(args); - - expect(args.lang).toBe("ts"); - expect(crash).not.toBeCalled(); - - args = { - ts: true, - lang: "ts", - }; - deriveCorrelatedArgs(args); - - expect(crash).toBeCalledWith( + expect(() => + deriveCorrelatedArgs({ + lang: "ts", + }), + ).not.toThrow(); + expect(() => + deriveCorrelatedArgs({ + ts: true, + lang: "ts", + }), + ).toThrow( "The `--ts` argument cannot be specified in conjunction with the `--lang` argument", ); }); diff --git a/packages/create-cloudflare/src/cli.ts b/packages/create-cloudflare/src/cli.ts index a05c821e3e42..a0218a7b6380 100644 --- a/packages/create-cloudflare/src/cli.ts +++ b/packages/create-cloudflare/src/cli.ts @@ -2,9 +2,16 @@ import { mkdirSync } from "fs"; import { dirname } from "path"; import { chdir } from "process"; -import { crash, endSection, logRaw, startSection } from "@cloudflare/cli"; +import { + cancel, + endSection, + error, + logRaw, + startSection, +} from "@cloudflare/cli"; +import { CancelError } from "@cloudflare/cli/error"; import { isInteractive } from "@cloudflare/cli/interactive"; -import { parseArgs } from "helpers/args"; +import { cliDefinition, parseArgs } from "helpers/args"; import { isUpdateAvailable } from "helpers/cli"; import { runCommand } from "helpers/command"; import { @@ -16,12 +23,13 @@ import { version } from "../package.json"; import { maybeOpenBrowser, offerToDeploy, runDeploy } from "./deploy"; import { printSummary, printWelcomeMessage } from "./dialog"; import { gitCommit, offerGit } from "./git"; +import { showHelp } from "./help"; +import { reporter, runTelemetryCommand } from "./metrics"; import { createProject } from "./pages"; import { addWranglerToGitIgnore, copyTemplateFiles, createContext, - deriveCorrelatedArgs, updatePackageName, updatePackageScripts, } from "./templates"; @@ -33,7 +41,29 @@ import type { C3Args, C3Context } from "types"; const { npm } = detectPackageManager(); export const main = async (argv: string[]) => { - const args = await parseArgs(argv); + const result = await parseArgs(argv); + + if (result.type === "unknown") { + if (result.showHelpMessage) { + showHelp(result.args, cliDefinition); + } + + if (result.errorMessage) { + console.error(`\n${result.errorMessage}`); + } + + if (result.args === null || result.errorMessage) { + process.exit(1); + } + return; + } + + if (result.type === "telemetry") { + runTelemetryCommand(result.action); + return; + } + + const { args } = result; // Print a newline logRaw(""); @@ -47,7 +77,13 @@ export const main = async (argv: string[]) => { ) { await runLatest(); } else { - await runCli(args); + await reporter.collectAsyncMetrics({ + eventPrefix: "c3 session", + props: { + args, + }, + promise: () => runCli(args), + }); } }; @@ -68,8 +104,6 @@ export const runLatest = async () => { export const runCli = async (args: Partial) => { printBanner(); - deriveCorrelatedArgs(args); - const ctx = await createContext(args); await create(ctx); @@ -85,7 +119,7 @@ export const setupProjectDirectory = (ctx: C3Context) => { const path = ctx.project.path; const err = validateProjectDirectory(path, ctx.args); if (err) { - crash(err); + throw new Error(err); } const directory = dirname(path); @@ -153,8 +187,18 @@ const deploy = async (ctx: C3Context) => { }; const printBanner = () => { - printWelcomeMessage(version); + printWelcomeMessage(version, reporter.isEnabled); startSection(`Create an application with Cloudflare`, "Step 1 of 3"); }; -main(process.argv).catch((e) => crash(e)); +main(process.argv) + .catch((e) => { + if (e instanceof CancelError) { + cancel(e.message); + } else { + error(e); + } + }) + .finally(async () => { + await reporter.waitForAllEventsSettled(); + }); diff --git a/packages/create-cloudflare/src/deploy.ts b/packages/create-cloudflare/src/deploy.ts index af38aed5d568..634226f5fc68 100644 --- a/packages/create-cloudflare/src/deploy.ts +++ b/packages/create-cloudflare/src/deploy.ts @@ -1,7 +1,7 @@ -import { crash, startSection, updateStatus } from "@cloudflare/cli"; -import { processArgument } from "@cloudflare/cli/args"; +import { startSection, updateStatus } from "@cloudflare/cli"; import { blue, brandColor, dim } from "@cloudflare/cli/colors"; import TOML from "@iarna/toml"; +import { processArgument } from "helpers/args"; import { C3_DEFAULTS, openInBrowser } from "helpers/cli"; import { quoteShellArgs, runCommand } from "helpers/command"; import { detectPackageManager } from "helpers/packageManagers"; @@ -46,7 +46,8 @@ export const offerToDeploy = async (ctx: C3Context) => { // initialize a deployment object in context ctx.deployment = {}; - const loginSuccess = await wranglerLogin(); + const loginSuccess = await wranglerLogin(ctx); + if (!loginSuccess) { return false; } @@ -79,8 +80,7 @@ export const runDeploy = async (ctx: C3Context) => { const { npm, name: pm } = detectPackageManager(); if (!ctx.account?.id) { - crash("Failed to read Cloudflare account."); - return; + throw new Error("Failed to read Cloudflare account."); } const baseDeployCmd = [npm, "run", ctx.template.deployScript ?? "deploy"]; @@ -117,7 +117,7 @@ export const runDeploy = async (ctx: C3Context) => { if (deployedUrlMatch) { ctx.deployment.url = deployedUrlMatch[0]; } else { - crash("Failed to find deployment url."); + throw new Error("Failed to find deployment url."); } // if a pages url (..pages.dev), remove the sha1 diff --git a/packages/create-cloudflare/src/dialog.ts b/packages/create-cloudflare/src/dialog.ts index e030be7dee63..c9ce0c5e1fd1 100644 --- a/packages/create-cloudflare/src/dialog.ts +++ b/packages/create-cloudflare/src/dialog.ts @@ -1,12 +1,5 @@ import { relative } from "path"; -import { - hyperlink, - linkRegex, - logRaw, - shapes, - space, - stripAnsi, -} from "@cloudflare/cli"; +import { hyperlink, logRaw, shapes, stripAnsi } from "@cloudflare/cli"; import { bgGreen, blue, gray } from "@cloudflare/cli/colors"; import { quoteShellArgs } from "helpers/command"; import { detectPackageManager } from "helpers/packageManagers"; @@ -15,91 +8,42 @@ import type { C3Context } from "types"; /** * Wrap the lines with a border and inner padding */ -export function createDialog( - lines: string[], - { - maxWidth = process.stdout.columns, - }: { - maxWidth?: number; - } = {}, -) { - const prefix = space(); - const border = shapes.bar; - const padding = " "; - const paddingWidth = padding.length * 2; - const borderWidth = border.length * 2; - const dialogWidth = paddingWidth + borderWidth + stripAnsi(prefix).length; - const ellipses = "..."; - - // Derive the outer width based on the content and max width - let innerWidth = Math.max( +export function createDialog(lines: string[]) { + const screenWidth = process.stdout.columns; + const maxLineWidth = Math.max( ...lines.map((line) => stripAnsi(line).length), 60, // Min inner width ); - - const maxInnerWidth = maxWidth - dialogWidth; - - // Limit the innerWidth to avoid overflow - if (innerWidth > maxInnerWidth) { - innerWidth = maxInnerWidth; - } - - const topRow = - gray(shapes.corners.tl) + - gray(shapes.dash.repeat(innerWidth + paddingWidth)) + - gray(shapes.corners.tr); - const bottomRow = - gray(shapes.corners.bl) + - gray(shapes.dash.repeat(innerWidth + paddingWidth)) + - gray(shapes.corners.br); + const dividerWidth = Math.min(maxLineWidth, screenWidth); return [ - prefix + topRow, - ...lines.map((line) => { - let lineWidth = stripAnsi(line).length; - - if (lineWidth > maxInnerWidth) { - // Truncate the label of the hyperlinks to avoid overflow - // Note: This assumes the label to have no ANSI code at the moment - line = line.replaceAll(linkRegex, (_, url, label) => - hyperlink( - url, - label.slice( - 0, - label.length - (lineWidth - maxInnerWidth) - ellipses.length, - ) + ellipses, - ), - ); - lineWidth = stripAnsi(line).length; - } - - if (lineWidth > maxInnerWidth) { - // If there is no link, truncate the text instead - // FIXME: This assumes the text to have no ANSI code at the moment - line = - line.slice(0, innerWidth - lineWidth - ellipses.length) + ellipses; - lineWidth = stripAnsi(line).length; - } - - return ( - prefix + - gray(border) + - padding + - line + - padding.repeat(innerWidth > lineWidth ? innerWidth - lineWidth : 0) + - padding + - gray(border) - ); - }), - prefix + bottomRow, + gray(shapes.dash).repeat(dividerWidth), + ...lines, + gray(shapes.dash).repeat(dividerWidth), + "", ].join("\n"); } -export function printWelcomeMessage(version: string) { - const dialog = createDialog([ +export function printWelcomeMessage( + version: string, + telemetryEnabled: boolean, +) { + const lines = [ `👋 Welcome to create-cloudflare v${version}!`, `🧡 Let's get started.`, - ]); + ]; + + if (telemetryEnabled) { + const telemetryDocsUrl = `https://github.com/cloudflare/workers-sdk/blob/main/packages/create-cloudflare/telemetry.md`; + + lines.push( + `📊 Cloudflare collects telemetry about your usage of Create-Cloudflare.`, + ``, + `Learn more at: ${blue.underline(hyperlink(telemetryDocsUrl))}`, + ); + } + + const dialog = createDialog(lines); logRaw(dialog); } @@ -134,25 +78,23 @@ export const printSummary = (ctx: C3Context) => { if (ctx.deployment.url && dashboardUrl) { lines.push( `🔍 View Project`, - ` ${gray("Visit:")} ${blue.underline(hyperlink(ctx.deployment.url))}`, - ` ${gray("Dash:")} ${blue.underline(hyperlink(dashboardUrl))}`, + `${gray("Visit:")} ${blue.underline(hyperlink(ctx.deployment.url))}`, + `${gray("Dash:")} ${blue.underline(hyperlink(dashboardUrl))}`, ``, ); } lines.push( `💻 Continue Developing`, - ...(cdCommand - ? [` ${gray("Change directories:")} ${blue(cdCommand)}`] - : []), - ` ${gray("Start dev server:")} ${blue(devServerCommand)}`, - ` ${gray(ctx.deployment.url ? `Deploy again:` : "Deploy:")} ${blue(deployCommand)}`, + ...(cdCommand ? [`${gray("Change directories:")} ${blue(cdCommand)}`] : []), + `${gray("Start dev server:")} ${blue(devServerCommand)}`, + `${gray(ctx.deployment.url ? `Deploy again:` : "Deploy:")} ${blue(deployCommand)}`, ``, `📖 Explore Documentation`, - ` ${blue.underline(hyperlink(documentationUrl))}`, + `${blue.underline(hyperlink(documentationUrl))}`, ``, `💬 Join our Community`, - ` ${blue.underline(hyperlink(discordUrl))}`, + `${blue.underline(hyperlink(discordUrl))}`, ); const dialog = createDialog(lines); diff --git a/packages/create-cloudflare/src/event.ts b/packages/create-cloudflare/src/event.ts new file mode 100644 index 000000000000..5f0a9051c35c --- /dev/null +++ b/packages/create-cloudflare/src/event.ts @@ -0,0 +1,615 @@ +import type { C3Args } from "./types"; +import type { PromptConfig } from "@cloudflare/cli/interactive"; + +export type Event = + | { + name: "c3 session started"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + }; + } + | { + name: "c3 session cancelled"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + + /** + * The duration of the prompt since it started in milliseconds (ms) + */ + durationMs?: number; + + /** + * The duration of the prompt since it started in seconds + */ + durationSeconds?: number; + + /** + * The duration of the prompt since it started in minutes + */ + durationMinutes?: number; + }; + } + | { + name: "c3 session errored"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + + /** + * The error that caused the session to be crashed + */ + error?: { + message: string | undefined; + stack: string | undefined; + }; + + /** + * The duration of the prompt since it started in milliseconds (ms) + */ + durationMs?: number; + + /** + * The duration of the prompt since it started in seconds + */ + durationSeconds?: number; + + /** + * The duration of the prompt since it started in minutes + */ + durationMinutes?: number; + }; + } + | { + name: "c3 session completed"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + + /** + * The duration of the prompt since it started in milliseconds (ms) + */ + durationMs?: number; + + /** + * The duration of the prompt since it started in seconds + */ + durationSeconds?: number; + + /** + * The duration of the prompt since it started in minutes + */ + durationMinutes?: number; + }; + } + | { + name: "c3 prompt started"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + + /** + * The argument key related to the prompt + */ + key?: string; + + /** + * An object containing all config passed to the prompt + */ + promptConfig?: PromptConfig; + }; + } + | { + name: "c3 prompt cancelled"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + + /** + * The argument key related to the prompt + */ + key?: string; + + /** + * An object containing all config passed to the prompt + */ + promptConfig?: PromptConfig; + + /** + * The duration of the prompt since it started in milliseconds (ms) + */ + durationMs?: number; + + /** + * The duration of the prompt since it started in seconds + */ + durationSeconds?: number; + + /** + * The duration of the prompt since it started in minutes + */ + durationMinutes?: number; + }; + } + | { + name: "c3 prompt errored"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + + /** + * The argument key related to the prompt + */ + key?: string; + + /** + * An object containing all config passed to the prompt + */ + promptConfig?: PromptConfig; + + /** + * The duration of the prompt since it started in milliseconds (ms) + */ + durationMs?: number; + + /** + * The duration of the prompt since it started in seconds + */ + durationSeconds?: number; + + /** + * The duration of the prompt since it started in minutes + */ + durationMinutes?: number; + + /** + * The error that caused the prompt to be crashed + */ + error?: { + message: string | undefined; + stack: string | undefined; + }; + }; + } + | { + name: "c3 prompt completed"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + + /** + * The argument key related to the prompt + */ + key?: string; + + /** + * An object containing all config passed to the prompt + */ + promptConfig?: PromptConfig; + + /** + * The duration of the prompt since it started in milliseconds (ms) + */ + durationMs?: number; + + /** + * The duration of the prompt since it started in seconds + */ + durationSeconds?: number; + + /** + * The duration of the prompt since it started in minutes + */ + durationMinutes?: number; + + /** + * The answer of the prompt. This could either be taken from the args provided or from the user input. + */ + answer?: unknown; + + /** + * Whether the answer is the same as the default value of the prompt. + */ + isDefaultValue?: boolean; + }; + } + | { + name: "c3 login started"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + }; + } + | { + name: "c3 login cancelled"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + + /** + * Whether the user was already logged in before running the CLI + */ + isAlreadyLoggedIn?: boolean; + + /** + * Whether the user successfully going through the login process if they were not already logged in + */ + isLoginSuccessful?: boolean; + + /** + * The duration of the prompt since it started in milliseconds (ms) + */ + durationMs?: number; + + /** + * The duration of the prompt since it started in seconds + */ + durationSeconds?: number; + + /** + * The duration of the prompt since it started in minutes + */ + durationMinutes?: number; + }; + } + | { + name: "c3 login errored"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + + /** + * Whether the user was already logged in before running the CLI + */ + isAlreadyLoggedIn?: boolean; + + /** + * Whether the user successfully going through the login process if they were not already logged in + */ + isLoginSuccessful?: boolean; + + /** + * The error that caused the session to be crashed + */ + error?: { + message: string | undefined; + stack: string | undefined; + }; + + /** + * The duration of the prompt since it started in milliseconds (ms) + */ + durationMs?: number; + + /** + * The duration of the prompt since it started in seconds + */ + durationSeconds?: number; + + /** + * The duration of the prompt since it started in minutes + */ + durationMinutes?: number; + }; + } + | { + name: "c3 login completed"; + properties: { + /** + * The OS platform the CLI is running on + * This could be "Mac OS", "Windows", "Linux", etc. + */ + platform?: string; + + /** + * The version of the create-cloudflare CLI used + */ + c3Version?: string; + + /** + * The name of the package manager used to run the CLI + */ + packageManager?: string; + + /** + * The CLI arguments set at the time the event is sent + */ + args?: Partial; + + /** + * Whether this is the first time the user is using the CLI + * Determined by checking if the user has a permission set in the metrics config + */ + isFirstUsage?: boolean; + + /** + * Whether the user was already logged in before running the CLI + */ + isAlreadyLoggedIn?: boolean; + + /** + * Whether the user successfully going through the login process if they were not already logged in + */ + isLoginSuccessful?: boolean; + + /** + * The duration of the prompt since it started in milliseconds (ms) + */ + durationMs?: number; + + /** + * The duration of the prompt since it started in seconds + */ + durationSeconds?: number; + + /** + * The duration of the prompt since it started in minutes + */ + durationMinutes?: number; + }; + }; diff --git a/packages/create-cloudflare/src/frameworks/index.ts b/packages/create-cloudflare/src/frameworks/index.ts index c9f9f5121b37..121978a0b9f3 100644 --- a/packages/create-cloudflare/src/frameworks/index.ts +++ b/packages/create-cloudflare/src/frameworks/index.ts @@ -1,4 +1,4 @@ -import { crash, logRaw, updateStatus } from "@cloudflare/cli"; +import { logRaw, updateStatus } from "@cloudflare/cli"; import { dim } from "@cloudflare/cli/colors"; import { quoteShellArgs, runCommand } from "helpers/command"; import { detectPackageManager } from "helpers/packageManagers"; @@ -8,7 +8,7 @@ import type { C3Context } from "types"; export const getFrameworkCli = (ctx: C3Context, withVersion = true) => { if (!ctx.template) { - return crash("Framework not specified."); + throw new Error("Framework not specified."); } const frameworkCli = ctx.template diff --git a/packages/create-cloudflare/src/git.ts b/packages/create-cloudflare/src/git.ts index 2e8b46cdd66b..2c1899861ed5 100644 --- a/packages/create-cloudflare/src/git.ts +++ b/packages/create-cloudflare/src/git.ts @@ -1,8 +1,8 @@ import { updateStatus } from "@cloudflare/cli"; -import { processArgument } from "@cloudflare/cli/args"; import { brandColor, dim } from "@cloudflare/cli/colors"; import { spinner } from "@cloudflare/cli/interactive"; import { getFrameworkCli } from "frameworks/index"; +import { processArgument } from "helpers/args"; import { C3_DEFAULTS } from "helpers/cli"; import { runCommand } from "helpers/command"; import { detectPackageManager } from "helpers/packageManagers"; diff --git a/packages/create-cloudflare/src/help.ts b/packages/create-cloudflare/src/help.ts index b8c55e8e4560..6332ab51857d 100644 --- a/packages/create-cloudflare/src/help.ts +++ b/packages/create-cloudflare/src/help.ts @@ -16,7 +16,7 @@ const MAX_WIDTH = 100; const PADDING_RIGHT = 5; export const showHelp = ( - args: C3Args | null, + args: Partial | null, { positionals, options, intro }: ArgumentsDefinition, ) => { const { name: pm } = detectPackageManager(); @@ -69,7 +69,10 @@ const renderPositionals = (positionals?: ArgDefinition[]) => { } }; -const renderOptions = (args: C3Args | null, options?: OptionDefinition[]) => { +const renderOptions = ( + args: Partial | null, + options?: OptionDefinition[], +) => { if (!options) { return; } diff --git a/packages/create-cloudflare/src/helpers/__tests__/args.test.ts b/packages/create-cloudflare/src/helpers/__tests__/args.test.ts index d0860ec4f915..2c7dbee7f8f1 100644 --- a/packages/create-cloudflare/src/helpers/__tests__/args.test.ts +++ b/packages/create-cloudflare/src/helpers/__tests__/args.test.ts @@ -1,54 +1,52 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { assert, describe, expect, test, vi } from "vitest"; import { parseArgs } from "../args"; -import type { MockInstance } from "vitest"; vi.mock("@cloudflare/cli"); vi.mock("yargs/helpers", () => ({ hideBin: (x: string[]) => x })); describe("Cli", () => { - let consoleErrorMock: MockInstance; - - beforeEach(() => { - // mock `console.error` for all tests in order to avoid noise - consoleErrorMock = vi.spyOn(console, "error").mockImplementation(() => {}); - }); - describe("parseArgs", () => { test("no arguments provide", async () => { const result = await parseArgs([]); - expect(result.projectName).toBeFalsy(); - expect(result.additionalArgs).toEqual([]); + + assert(result.type === "default"); + expect(result.args.projectName).toBeFalsy(); + expect(result.args.additionalArgs).toEqual([]); }); test("parsing the first argument as the projectName", async () => { const result = await parseArgs(["my-project"]); - expect(result.projectName).toBe("my-project"); + + assert(result.type === "default"); + expect(result.args.projectName).toBe("my-project"); }); test("too many positional arguments provided", async () => { - const processExitMock = vi - .spyOn(process, "exit") - .mockImplementation(() => null as never); - - await parseArgs(["my-project", "123"]); + const result = await parseArgs(["my-project", "123"]); - expect(consoleErrorMock).toHaveBeenCalledWith( - expect.stringMatching(/Too many positional arguments provided/), + assert(result.type === "unknown"); + expect(result.showHelpMessage).toBe(true); + expect(result.args).not.toBe(null); + expect(result.errorMessage).toBe( + "Too many positional arguments provided", ); - expect(processExitMock).toHaveBeenCalledWith(1); }); test("not parsing first argument as the projectName if it is after --", async () => { const result = await parseArgs(["--", "my-project"]); - expect(result.projectName).toBeFalsy(); + + assert(result.type === "default"); + expect(result.args.projectName).toBeFalsy(); }); test("parsing optional C3 arguments correctly", async () => { const result = await parseArgs(["--framework", "angular", "--ts=true"]); - expect(result.projectName).toBeFalsy(); - expect(result.framework).toEqual("angular"); - expect(result.ts).toEqual(true); - expect(result.additionalArgs).toEqual([]); + + assert(result.type === "default"); + expect(result.args.projectName).toBeFalsy(); + expect(result.args.framework).toEqual("angular"); + expect(result.args.ts).toEqual(true); + expect(result.args.additionalArgs).toEqual([]); }); test("parsing positional + optional C3 arguments correctly", async () => { @@ -60,11 +58,13 @@ describe("Cli", () => { "true", "--git=false", ]); - expect(result.projectName).toEqual("my-project"); - expect(result.framework).toEqual("angular"); - expect(result.deploy).toEqual(true); - expect(result.git).toEqual(false); - expect(result.additionalArgs).toEqual([]); + + assert(result.type === "default"); + expect(result.args.projectName).toEqual("my-project"); + expect(result.args.framework).toEqual("angular"); + expect(result.args.deploy).toEqual(true); + expect(result.args.git).toEqual(false); + expect(result.args.additionalArgs).toEqual([]); }); test("parsing optional C3 arguments + additional arguments correctly", async () => { @@ -77,10 +77,12 @@ describe("Cli", () => { "--react-option", "5", ]); - expect(result.projectName).toBeFalsy(); - expect(result.framework).toEqual("react"); - expect(result.ts).toEqual(true); - expect(result.additionalArgs).toEqual([ + + assert(result.type === "default"); + expect(result.args.projectName).toBeFalsy(); + expect(result.args.framework).toEqual("react"); + expect(result.args.ts).toEqual(true); + expect(result.args.additionalArgs).toEqual([ "positional-arg", "--react-option", "5", @@ -98,10 +100,12 @@ describe("Cli", () => { "--react-option", "5", ]); - expect(result.projectName).toBe("my-react-project"); - expect(result.framework).toEqual("react"); - expect(result.ts).toEqual(true); - expect(result.additionalArgs).toEqual([ + + assert(result.type === "default"); + expect(result.args.projectName).toBe("my-react-project"); + expect(result.args.framework).toEqual("react"); + expect(result.args.ts).toEqual(true); + expect(result.args.additionalArgs).toEqual([ "positional-arg", "--react-option", "5", @@ -115,7 +119,10 @@ describe("Cli", () => { "--existing-script", ]; test.each(stringArgs)("%s requires an argument", async (arg) => { - await expect(parseArgs(["my-react-project", arg])).rejects.toThrowError(); + await expect(parseArgs(["my-react-project", arg])).resolves.toEqual({ + type: "unknown", + args: null, + }); }); }); }); diff --git a/packages/create-cloudflare/src/helpers/__tests__/command.test.ts b/packages/create-cloudflare/src/helpers/__tests__/command.test.ts index d924245b53b9..cfacfc987a34 100644 --- a/packages/create-cloudflare/src/helpers/__tests__/command.test.ts +++ b/packages/create-cloudflare/src/helpers/__tests__/command.test.ts @@ -51,6 +51,7 @@ describe("Command Helpers", () => { expect(spawn).toHaveBeenCalledWith("ls", ["-l"], { stdio: "inherit", env: process.env, + signal: expect.any(AbortSignal), }); }); diff --git a/packages/create-cloudflare/src/helpers/args.ts b/packages/create-cloudflare/src/helpers/args.ts index 5d433f9eeecf..85be81c0be83 100644 --- a/packages/create-cloudflare/src/helpers/args.ts +++ b/packages/create-cloudflare/src/helpers/args.ts @@ -1,13 +1,15 @@ +import { inputPrompt } from "@cloudflare/cli/interactive"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { version } from "../../package.json"; -import { showHelp } from "../help"; +import { reporter } from "../metrics"; import { getFrameworkMap, getNamesAndDescriptions, getTemplateMap, } from "../templates"; import { C3_DEFAULTS, WRANGLER_DEFAULTS } from "./cli"; +import type { PromptConfig } from "@cloudflare/cli/interactive"; import type { C3Args } from "types"; import type { Argv } from "yargs"; @@ -25,7 +27,7 @@ export type OptionDefinition = { footer?: string; values?: | AllowedValueDefinition[] - | ((args: C3Args | null) => AllowedValueDefinition[]); + | ((args: Partial | null) => AllowedValueDefinition[]); } & ArgDefinition; export type AllowedValueDefinition = { @@ -39,7 +41,7 @@ export type ArgumentsDefinition = { options: OptionDefinition[]; }; -const cliDefinition: ArgumentsDefinition = { +export const cliDefinition: ArgumentsDefinition = { intro: ` The create-cloudflare cli (also known as C3) is a command-line tool designed to help you set up and deploy new applications to Cloudflare. In addition to speed, it leverages officially developed templates for Workers and framework-specific setup guides to ensure each new application that you set up follows Cloudflare and any third-party best practices for deployment on the Cloudflare network. `, @@ -245,7 +247,24 @@ const cliDefinition: ArgumentsDefinition = { ], }; -export const parseArgs = async (argv: string[]): Promise> => { +export const parseArgs = async ( + argv: string[], +): Promise< + | { + type: "default"; + args: Partial; + } + | { + type: "telemetry"; + action: "enable" | "disable" | "status"; + } + | { + type: "unknown"; + args: Partial | null; + showHelpMessage?: boolean; + errorMessage?: string; + } +> => { const doubleDashesIdx = argv.indexOf("--"); const c3Args = argv.slice( 0, @@ -254,12 +273,33 @@ export const parseArgs = async (argv: string[]): Promise> => { const additionalArgs = doubleDashesIdx < 0 ? [] : argv.slice(doubleDashesIdx + 1); + const c3positionalArgs = c3Args.filter((arg) => !arg.startsWith("-")); + + if ( + c3positionalArgs[2] === "telemetry" && + c3positionalArgs[3] !== undefined + ) { + const action = c3positionalArgs[3]; + + switch (action) { + case "enable": + case "disable": + case "status": + return { + type: "telemetry", + action, + }; + default: + throw new Error(`Unknown subcommand "telemetry ${action}"`); + } + } + const yargsObj = yargs(hideBin(c3Args)) .scriptName("create-cloudflare") .usage("$0 [args]") .version(version) .alias("v", "version") - .help(false) as unknown as Argv; + .help(false) as unknown as Argv>; const { positionals, options } = cliDefinition; if (positionals) { @@ -270,11 +310,7 @@ export const parseArgs = async (argv: string[]): Promise> => { if (options) { for (const { name, alias, ...props } of options) { - const values = - typeof props.values === "function" - ? await props.values(await yargsObj.argv) - : props.values; - yargsObj.option(name, { values, ...props }); + yargsObj.option(name, props); if (alias) { yargsObj.alias(alias, name); } @@ -288,42 +324,52 @@ export const parseArgs = async (argv: string[]): Promise> => { } catch {} if (args === null) { - showHelp(args, cliDefinition); - process.exit(1); - } - - if (args.version) { - process.exit(0); + return { + type: "unknown", + args, + }; } - if (args.help) { - showHelp(args, cliDefinition); - process.exit(0); + if (args.version || args.help) { + return { + type: "unknown", + showHelpMessage: args.help, + args, + }; } const positionalArgs = args._; for (const opt in args) { if (!validOption(opt)) { - showHelp(args, cliDefinition); - console.error(`\nUnrecognized option: ${opt}`); - process.exit(1); + return { + type: "unknown", + showHelpMessage: true, + args, + errorMessage: `Unrecognized option: ${opt}`, + }; } } // since `yargs.strict()` can't check the `positional`s for us we need to do it manually ourselves if (positionalArgs.length > 1) { - showHelp(args, cliDefinition); - console.error("\nToo many positional arguments provided"); - process.exit(1); + return { + type: "unknown", + showHelpMessage: true, + args, + errorMessage: "Too many positional arguments provided", + }; } return { - ...(args.wranglerDefaults && WRANGLER_DEFAULTS), - ...(args.acceptDefaults && C3_DEFAULTS), - ...args, - additionalArgs, - projectName: positionalArgs[0] as string | undefined, + type: "default", + args: { + ...(args.wranglerDefaults && WRANGLER_DEFAULTS), + ...(args.acceptDefaults && C3_DEFAULTS), + ...args, + additionalArgs, + projectName: positionalArgs[0] as string | undefined, + }, }; }; @@ -351,3 +397,40 @@ const validOption = (opt: string) => { }; const camelize = (str: string) => str.replace(/-./g, (x) => x[1].toUpperCase()); + +export const processArgument = async ( + args: Partial, + key: Key, + promptConfig: PromptConfig, +) => { + return await reporter.collectAsyncMetrics({ + eventPrefix: "c3 prompt", + props: { + args, + key, + promptConfig, + }, + // Skip metrics collection if the arg value is already set + // This can happen when the arg is set via the CLI or if the user has already answered the prompt previously + disableTelemetry: args[key] !== undefined, + async promise() { + const value = args[key]; + const result = await inputPrompt[Key]>({ + ...promptConfig, + // Accept the default value if the arg is already set + acceptDefault: promptConfig.acceptDefault ?? value !== undefined, + defaultValue: value ?? promptConfig.defaultValue, + throwOnError: true, + }); + + // Update value in args before returning the result + args[key] = result; + + // Set properties for prompt completed event + reporter.setEventProperty("answer", result); + reporter.setEventProperty("isDefaultValue", result === C3_DEFAULTS[key]); + + return result; + }, + }); +}; diff --git a/packages/create-cloudflare/src/helpers/codemod.ts b/packages/create-cloudflare/src/helpers/codemod.ts index b58cdc692830..ae7e1313e4f8 100644 --- a/packages/create-cloudflare/src/helpers/codemod.ts +++ b/packages/create-cloudflare/src/helpers/codemod.ts @@ -1,6 +1,5 @@ import { existsSync, lstatSync, readdirSync } from "fs"; import path, { extname, join } from "path"; -import { crash } from "@cloudflare/cli"; import * as recast from "recast"; import * as esprimaParser from "recast/parsers/esprima"; import * as typescriptParser from "recast/parsers/typescript"; @@ -34,7 +33,7 @@ export const parseJs = (src: string) => { try { return recast.parse(src, { parser: esprimaParser }); } catch (error) { - crash("Error parsing js template."); + throw new Error("Error parsing js template."); } }; @@ -44,7 +43,7 @@ export const parseTs = (src: string) => { try { return recast.parse(src, { parser: typescriptParser }); } catch (error) { - crash("Error parsing ts template."); + throw new Error("Error parsing ts template."); } }; @@ -61,7 +60,7 @@ export const parseFile = (filePath: string) => { return recast.parse(fileContents, { parser }).program as Program; } } catch (error) { - crash(`Error parsing file: ${filePath}`); + throw new Error(`Error parsing file: ${filePath}`); } return null; diff --git a/packages/create-cloudflare/src/helpers/command.ts b/packages/create-cloudflare/src/helpers/command.ts index ffecb0bb6e3f..51547c2ad462 100644 --- a/packages/create-cloudflare/src/helpers/command.ts +++ b/packages/create-cloudflare/src/helpers/command.ts @@ -1,4 +1,5 @@ import { stripAnsi } from "@cloudflare/cli"; +import { CancelError } from "@cloudflare/cli/error"; import { isInteractive, spinner } from "@cloudflare/cli/interactive"; import { spawn } from "cross-spawn"; @@ -51,7 +52,7 @@ export const runCommand = async ( doneText: opts.doneText, promise() { const [executable, ...args] = command; - + const abortController = new AbortController(); const cmd = spawn(executable, [...args], { // TODO: ideally inherit stderr, but npm install uses this for warnings // stdio: [ioMode, ioMode, "inherit"], @@ -61,6 +62,7 @@ export const runCommand = async ( ...opts.env, }, cwd: opts.cwd, + signal: abortController.signal, }); let output = ``; @@ -74,7 +76,21 @@ export const runCommand = async ( }); } + let cleanup: (() => void) | null = null; + return new Promise((resolvePromise, reject) => { + const cancel = (signal?: NodeJS.Signals) => { + reject(new CancelError(`Command cancelled`, signal)); + abortController.abort(signal ? `${signal} received` : null); + }; + + process.on("SIGTERM", cancel).on("SIGINT", cancel); + + // To cleanup the signal listeners when the promise settles + cleanup = () => { + process.off("SIGTERM", cancel).off("SIGINT", cancel); + }; + cmd.on("close", (code) => { try { if (code !== 0) { @@ -95,9 +111,11 @@ export const runCommand = async ( } }); - cmd.on("error", (code) => { - reject(code); + cmd.on("error", (error) => { + reject(error); }); + }).finally(() => { + cleanup?.(); }); }, }); diff --git a/packages/create-cloudflare/src/helpers/files.ts b/packages/create-cloudflare/src/helpers/files.ts index bf138d079a32..b3ef3cf3bbd1 100644 --- a/packages/create-cloudflare/src/helpers/files.ts +++ b/packages/create-cloudflare/src/helpers/files.ts @@ -1,6 +1,5 @@ import fs, { existsSync, statSync } from "fs"; import { join } from "path"; -import { crash } from "@cloudflare/cli"; import TOML from "@iarna/toml"; import type { C3Context } from "types"; @@ -8,7 +7,7 @@ export const copyFile = (path: string, dest: string) => { try { fs.copyFileSync(path, dest); } catch (error) { - crash(error as string); + throw new Error(error as string); } }; @@ -16,7 +15,7 @@ export const writeFile = (path: string, content: string) => { try { fs.writeFileSync(path, content); } catch (error) { - crash(error as string); + throw new Error(error as string); } }; @@ -24,7 +23,7 @@ export const appendFile = (path: string, content: string) => { try { fs.appendFileSync(path, content); } catch (error) { - crash(error as string); + throw new Error(error as string); } }; @@ -32,7 +31,7 @@ export const readFile = (path: string) => { try { return fs.readFileSync(path, "utf-8"); } catch (error) { - return crash(error as string); + throw new Error(error as string); } }; @@ -40,7 +39,7 @@ export const removeFile = (path: string) => { try { fs.rmSync(path, { force: true }); } catch (error) { - crash(error as string); + throw new Error(`Remove file failed: ${path}`, { cause: error }); } }; @@ -52,7 +51,7 @@ export const directoryExists = (path: string): boolean => { if ((error as { code: string }).code === "ENOENT") { return false; } - return crash(error as string); + throw new Error(error as string); } }; diff --git a/packages/create-cloudflare/src/helpers/global-wrangler-config-path.ts b/packages/create-cloudflare/src/helpers/global-wrangler-config-path.ts new file mode 100644 index 000000000000..3d8cd91525e6 --- /dev/null +++ b/packages/create-cloudflare/src/helpers/global-wrangler-config-path.ts @@ -0,0 +1,27 @@ +// Copied from packages/wrangler/src/global-wrangler-config-path.ts with no modification +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import xdgAppPaths from "xdg-app-paths"; + +function isDirectory(configPath: string) { + try { + return fs.statSync(configPath).isDirectory(); + } catch (error) { + // ignore error + return false; + } +} + +export function getGlobalWranglerConfigPath() { + //TODO: We should implement a custom path --global-config and/or the WRANGLER_HOME type environment variable + const configDir = xdgAppPaths(".wrangler").config(); // New XDG compliant config path + const legacyConfigDir = path.join(os.homedir(), ".wrangler"); // Legacy config in user's home directory + + // Check for the .wrangler directory in root if it is not there then use the XDG compliant path. + if (isDirectory(legacyConfigDir)) { + return legacyConfigDir; + } else { + return configDir; + } +} diff --git a/packages/create-cloudflare/src/helpers/metrics-config.ts b/packages/create-cloudflare/src/helpers/metrics-config.ts new file mode 100644 index 000000000000..5a3bbfd1d2c1 --- /dev/null +++ b/packages/create-cloudflare/src/helpers/metrics-config.ts @@ -0,0 +1,82 @@ +// Copied from packages/wrangler/src/metrics/metrics-config.ts with the following changes: +// - Removed methods not required for c3 +// - Added `c3permission` property to the `MetricsConfigFile` interface +// - Exported the `getDeviceId` helper + +import { randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { getGlobalWranglerConfigPath } from "./global-wrangler-config-path"; + +export const USER_ID_CACHE_PATH = "user-id.json"; + +/** + * Stringify and write the given info to the metrics config file. + */ +export function writeMetricsConfig(config: MetricsConfigFile) { + mkdirSync(path.dirname(getMetricsConfigPath()), { recursive: true }); + writeFileSync( + getMetricsConfigPath(), + JSON.stringify( + config, + (_key, value) => (value instanceof Date ? value.toISOString() : value), + "\t", + ), + ); +} + +/** + * Read and parse the metrics config file. + */ +export function readMetricsConfig(): MetricsConfigFile { + try { + const config = readFileSync(getMetricsConfigPath(), "utf8"); + return JSON.parse(config, (key, value) => + key === "date" ? new Date(value) : value, + ); + } catch { + return {}; + } +} + +/** + * Get the path to the metrics config file. + */ +function getMetricsConfigPath(): string { + return path.resolve(getGlobalWranglerConfigPath(), "metrics.json"); +} + +/** + * The format of the metrics config file. + */ +export interface MetricsConfigFile { + permission?: { + /** True if Wrangler should send metrics to Cloudflare. */ + enabled: boolean; + /** The date that this permission was set. */ + date: Date; + }; + 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; +} + +/** + * Returns an ID that uniquely identifies Wrangler on this device to help collate events. + * + * Once created this ID is stored in the metrics config file. + */ +export function getDeviceId(config: MetricsConfigFile) { + // Get or create the deviceId. + const deviceId = config.deviceId ?? randomUUID(); + if (config.deviceId === undefined) { + // We had to create a new deviceID so store it now. + writeMetricsConfig({ ...config, deviceId }); + } + return deviceId; +} diff --git a/packages/create-cloudflare/src/helpers/sparrow.ts b/packages/create-cloudflare/src/helpers/sparrow.ts new file mode 100644 index 000000000000..ee25f33b7c40 --- /dev/null +++ b/packages/create-cloudflare/src/helpers/sparrow.ts @@ -0,0 +1,32 @@ +import { fetch } from "undici"; + +// 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 SPARROW_URL: string = "https://sparrow.cloudflare.com"; + +export type EventPayload = { + event: string; + deviceId: string; + timestamp: number | undefined; + properties: Record; +}; + +export function hasSparrowSourceKey() { + return SPARROW_SOURCE_KEY !== ""; +} + +export async function sendEvent(payload: EventPayload) { + if (process.env.CREATE_CLOUDFLARE_TELEMETRY_DEBUG === "1") { + console.log("[telemetry]", JSON.stringify(payload, null, 2)); + } + + await fetch(`${SPARROW_URL}/api/v1/event`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Sparrow-Source-Key": SPARROW_SOURCE_KEY, + }, + body: JSON.stringify(payload), + }); +} diff --git a/packages/create-cloudflare/src/metrics.ts b/packages/create-cloudflare/src/metrics.ts new file mode 100644 index 000000000000..ea14d1df6142 --- /dev/null +++ b/packages/create-cloudflare/src/metrics.ts @@ -0,0 +1,325 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { setTimeout } from "node:timers/promises"; +import { logRaw } from "@cloudflare/cli"; +import { CancelError } from "@cloudflare/cli/error"; +import { + getDeviceId, + readMetricsConfig, + writeMetricsConfig, +} from "helpers/metrics-config"; +import { detectPackageManager } from "helpers/packageManagers"; +import * as sparrow from "helpers/sparrow"; +import { version as c3Version } from "../package.json"; +import type { Event } from "./event"; + +// A type to extract the prefix of event names sharing the same suffix +type EventPrefix = + Event["name"] extends `${infer Name} ${Suffix}` ? Name : never; + +// A type to get all possible keys of a union type +type KeysOfUnion = Obj extends Obj ? keyof Obj : never; + +// A type to extract the properties of an event based on the name +type EventProperties = Extract< + Event, + { name: EventName } +>["properties"]; + +// A method returns an object containing a new Promise object and two functions to resolve or reject it. +// This can be replaced with `Promise.withResolvers()` when it is available +export function promiseWithResolvers() { + let resolve: ((value: T) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + if (!resolve || !reject) { + throw new Error("Promise resolvers not set"); + } + + return { resolve, reject, promise }; +} + +export function getPlatform() { + const platform = process.platform; + + switch (platform) { + case "win32": + return "Windows"; + case "darwin": + return "Mac OS"; + case "linux": + return "Linux"; + default: + return `Others: ${platform}`; + } +} + +export function createReporter() { + const events: Array> = []; + const als = new AsyncLocalStorage<{ + setEventProperty: (key: string, value: unknown) => void; + }>(); + + const config = readMetricsConfig() ?? {}; + const isFirstUsage = config.c3permission === undefined; + const isEnabled = isTelemetryEnabled(); + const deviceId = getDeviceId(config); + const packageManager = detectPackageManager(); + const platform = getPlatform(); + const amplitude_session_id = Date.now(); + + // The event id is an incrementing counter to distinguish events with the same `user_id` and timestamp from each other. + // @see https://amplitude.com/docs/apis/analytics/http-v2#event-array-keys + let amplitude_event_id = 0; + + function sendEvent( + name: EventName, + properties: EventProperties, + ): void { + if (!isEnabled) { + return; + } + + const request = sparrow.sendEvent({ + event: name, + deviceId, + timestamp: Date.now(), + properties: { + amplitude_session_id, + amplitude_event_id: amplitude_event_id++, + platform, + c3Version, + isFirstUsage, + packageManager: packageManager.name, + ...properties, + }, + }); + + // TODO(consider): retry failed requests + // TODO(consider): add a timeout to avoid the process staying alive for too long + + events.push(request); + } + + function isTelemetryEnabled() { + if (process.env.CREATE_CLOUDFLARE_TELEMETRY_DISABLED === "1") { + return false; + } + + return sparrow.hasSparrowSourceKey() && getC3Permission(config).enabled; + } + + async function waitForAllEventsSettled(): Promise { + await Promise.allSettled(events); + } + + function createTracker< + Prefix extends EventPrefix< + "started" | "cancelled" | "errored" | "completed" + >, + >(eventPrefix: Prefix, props: EventProperties<`${Prefix} started`>) { + let startTime: number | null = null; + const additionalProperties: Record = {}; + + function submitEvent(name: Event["name"]) { + if (!startTime) { + startTime = Date.now(); + } else { + const ms = Date.now() - startTime; + + additionalProperties["durationMs"] = ms; + additionalProperties["durationSeconds"] = ms / 1000; + additionalProperties["durationMinutes"] = ms / 1000 / 60; + } + + sendEvent(name, { + ...props, + ...additionalProperties, + }); + } + + return { + setEventProperty(key: string, value: unknown) { + additionalProperties[key] = value; + }, + started() { + submitEvent(`${eventPrefix} started`); + }, + completed() { + submitEvent(`${eventPrefix} completed`); + }, + cancelled(signal?: NodeJS.Signals) { + additionalProperties["signal"] = signal; + + submitEvent(`${eventPrefix} cancelled`); + }, + errored(error: unknown) { + additionalProperties["error"] = { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }; + + submitEvent(`${eventPrefix} errored`); + }, + }; + } + + // Collect metrics for an async function + // This tracks each stages of the async function and sends the corresonding event to sparrow + async function collectAsyncMetrics< + Prefix extends EventPrefix< + "started" | "cancelled" | "errored" | "completed" + >, + Result, + >(options: { + eventPrefix: Prefix; + props: EventProperties<`${Prefix} started`>; + disableTelemetry?: boolean; + promise: () => Promise; + }): Promise { + // Create a new promise that will reject when the user interrupts the process + const cancelDeferred = promiseWithResolvers(); + const cancel = async (signal?: NodeJS.Signals) => { + // Let subtasks handles the signals first with a short timeout + await setTimeout(10); + + cancelDeferred.reject(new CancelError(`Operation cancelled`, signal)); + }; + const tracker = !options.disableTelemetry + ? createTracker(options.eventPrefix, options.props) + : null; + + try { + tracker?.started(); + + // Attach the SIGINT and SIGTERM event listeners to handle cancellation + process.on("SIGINT", cancel).on("SIGTERM", cancel); + + const result = await Promise.race([ + // The deferred promise will reject when the user interrupts the process + cancelDeferred.promise, + als.run( + { + // This allows the promise to use the `setEventProperty` helper to + // update the properties object sent to sparrow + setEventProperty(key, value) { + tracker?.setEventProperty(key, value); + }, + }, + options.promise, + ), + ]); + + tracker?.completed(); + + return result; + } catch (e) { + if (e instanceof CancelError) { + tracker?.cancelled(e.signal); + } else { + tracker?.errored(e); + } + + // Rethrow the error so it can be caught by the caller + throw e; + } finally { + // Clean up the event listeners + process.off("SIGINT", cancel).off("SIGTERM", cancel); + } + } + + // To be used within `collectAsyncMetrics` to update the properties object sent to sparrow + function setEventProperty>( + key: Key, + value: unknown, + ) { + const store = als.getStore(); + + // Throw only on test environment to avoid breaking the CLI + if (!store && process.env.VITEST) { + throw new Error( + "`setEventProperty` must be called within `collectAsyncMetrics`", + ); + } + + store?.setEventProperty(key, value); + } + + return { + sendEvent, + waitForAllEventsSettled, + collectAsyncMetrics, + setEventProperty, + isEnabled, + }; +} + +// A singleton instance of the reporter that can be imported and used across the codebase +export const reporter = createReporter(); + +export function initializeC3Permission(enabled = true) { + return { + enabled, + date: new Date(), + }; +} + +export function getC3Permission(config = readMetricsConfig() ?? {}) { + if (!config.c3permission) { + config.c3permission = initializeC3Permission(); + + writeMetricsConfig(config); + } + + return config.c3permission; +} + +// To update the c3permission property in the metrics config +export function updateC3Pemission(enabled: boolean) { + const config = readMetricsConfig(); + + if (config.c3permission?.enabled === enabled) { + // Do nothing if the enabled state is the same + return; + } + + config.c3permission = initializeC3Permission(enabled); + + writeMetricsConfig(config); +} + +export const runTelemetryCommand = ( + action: "status" | "enable" | "disable", +) => { + const logTelemetryStatus = (enabled: boolean) => { + logRaw(`Status: ${enabled ? "Enabled" : "Disabled"}`); + logRaw(""); + }; + + switch (action) { + case "enable": { + updateC3Pemission(true); + logTelemetryStatus(true); + logRaw( + "Create-Cloudflare is now collecting telemetry about your usage. Thank you for helping us improve the experience!", + ); + break; + } + case "disable": { + updateC3Pemission(false); + logTelemetryStatus(false); + logRaw("Create-Cloudflare is no longer collecting telemetry"); + break; + } + case "status": { + const telemetry = getC3Permission(); + + logTelemetryStatus(telemetry.enabled); + break; + } + } +}; diff --git a/packages/create-cloudflare/src/pages.ts b/packages/create-cloudflare/src/pages.ts index 50e28f7accd5..7ce099c3c9b8 100644 --- a/packages/create-cloudflare/src/pages.ts +++ b/packages/create-cloudflare/src/pages.ts @@ -1,4 +1,3 @@ -import { crash } from "@cloudflare/cli"; import { brandColor, dim } from "@cloudflare/cli/colors"; import { quoteShellArgs, runCommand } from "helpers/command"; import { detectPackageManager } from "helpers/packageManagers"; @@ -19,8 +18,7 @@ export const createProject = async (ctx: C3Context) => { return; } if (!ctx.account?.id) { - crash("Failed to read Cloudflare account."); - return; + throw new Error("Failed to read Cloudflare account."); } const CLOUDFLARE_ACCOUNT_ID = ctx.account.id; @@ -68,7 +66,7 @@ export const createProject = async (ctx: C3Context) => { }), ); } catch (error) { - crash("Failed to create pages project. See output above."); + throw new Error("Failed to create pages project. See output above."); } // Wait until the pages project is available for deployment @@ -95,6 +93,8 @@ export const createProject = async (ctx: C3Context) => { }), ); } catch (error) { - crash("Pages project isn't ready yet. Please try deploying again later."); + throw new Error( + "Pages project isn't ready yet. Please try deploying again later.", + ); } }; diff --git a/packages/create-cloudflare/src/templates.ts b/packages/create-cloudflare/src/templates.ts index 6dbeeb8f0da9..a26088750965 100644 --- a/packages/create-cloudflare/src/templates.ts +++ b/packages/create-cloudflare/src/templates.ts @@ -2,12 +2,12 @@ import { existsSync } from "fs"; import { cp, mkdtemp, rename } from "fs/promises"; import { tmpdir } from "os"; import { basename, dirname, join, resolve } from "path"; -import { crash, shapes, updateStatus, warn } from "@cloudflare/cli"; -import { processArgument } from "@cloudflare/cli/args"; +import { shapes, updateStatus, warn } from "@cloudflare/cli"; import { blue, brandColor, dim } from "@cloudflare/cli/colors"; import { spinner } from "@cloudflare/cli/interactive"; import deepmerge from "deepmerge"; import degit from "degit"; +import { processArgument } from "helpers/args"; import { C3_DEFAULTS } from "helpers/cli"; import { appendFile, @@ -269,7 +269,7 @@ export const deriveCorrelatedArgs = (args: Partial) => { const language = args.ts ? "ts" : "js"; if (args.lang !== undefined) { - crash( + throw new Error( "The `--ts` argument cannot be specified in conjunction with the `--lang` argument", ); } @@ -287,28 +287,32 @@ export const createContext = async ( args: Partial, prevArgs?: Partial, ): Promise => { + // Derive all correlated arguments first so we can skip some prompts + deriveCorrelatedArgs(args); + // Allows the users to go back to the previous step // By moving the cursor up to a certain line and clearing the screen const goBack = async (from: "type" | "framework" | "lang") => { - const newArgs = { ...args }; + const currentArgs = { ...args }; let linesPrinted = 0; switch (from) { case "type": linesPrinted = 9; - newArgs.category = undefined; + args.category = undefined; break; case "framework": linesPrinted = 9; - newArgs.category = undefined; + args.category = undefined; break; case "lang": linesPrinted = 12; - newArgs.type = undefined; + args.type = undefined; break; } - newArgs[from] = undefined; + // To remove the BACK_VALUE from the result args + currentArgs[from] = undefined; args[from] = undefined; if (process.stdout.isTTY) { @@ -316,7 +320,7 @@ export const createContext = async ( process.stdout.clearScreenDown(); } - return await createContext(newArgs, args); + return await createContext(args, currentArgs); }; // The option to go back to the previous step @@ -329,7 +333,7 @@ export const createContext = async ( }; const defaultName = args.existingScript || C3_DEFAULTS.projectName; - const projectName = await processArgument(args, "projectName", { + const projectName = await processArgument(args, "projectName", { type: "text", question: `In which directory do you want to create your application?`, helpText: "also used as application name", @@ -366,7 +370,7 @@ export const createContext = async ( { label: "Others", value: "others", hidden: true }, ]; - const category = await processArgument(args, "category", { + const category = await processArgument(args, "category", { type: "select", question: "What would you like to start with?", label: "category", @@ -387,7 +391,7 @@ export const createContext = async ( }), ); - const framework = await processArgument(args, "framework", { + const framework = await processArgument(args, "framework", { type: "select", label: "framework", question: "Which development framework do you want to use?", @@ -402,7 +406,7 @@ export const createContext = async ( const frameworkConfig = frameworkMap[framework]; if (!frameworkConfig) { - crash(`Unsupported framework: ${framework}`); + throw new Error(`Unsupported framework: ${framework}`); } template = { @@ -433,7 +437,7 @@ export const createContext = async ( }, ); - const type = await processArgument(args, "type", { + const type = await processArgument(args, "type", { type: "select", question: "Which template would you like to use?", label: "type", @@ -448,7 +452,7 @@ export const createContext = async ( template = templateMap[type]; if (!template) { - return crash(`Unknown application type provided: ${type}.`); + throw new Error(`Unknown application type provided: ${type}.`); } } @@ -478,7 +482,7 @@ export const createContext = async ( { label: "Python (beta)", value: "python" }, ]; - const lang = await processArgument(args, "lang", { + const lang = await processArgument(args, "lang", { type: "select", question: "Which language do you want to use?", label: "lang", @@ -501,10 +505,9 @@ export const createContext = async ( return { project: { name, path }, - args: { - ...args, - projectName, - }, + // We need to maintain a reference to the original args + // To ensure that we send the latest args to Sparrow + args: Object.assign(args, { projectName }), template, originalCWD, gitRepoAlreadyExisted: await isInsideGitRepo(directory), @@ -532,7 +535,9 @@ export async function copyTemplateFiles(ctx: C3Context) { const variantInfo = variant ? copyFiles.variants[variant] : null; if (!variantInfo) { - crash(`Unknown variant provided: ${JSON.stringify(variant ?? "")}`); + throw new Error( + `Unknown variant provided: ${JSON.stringify(variant ?? "")}`, + ); } srcdir = join(getTemplatePath(ctx), variantInfo.path); @@ -557,7 +562,7 @@ export async function copyTemplateFiles(ctx: C3Context) { } export const processRemoteTemplate = async (args: Partial) => { - const templateUrl = await processArgument(args, "template", { + const templateUrl = await processArgument(args, "template", { type: "text", question: "What's the url of git repo containing the template you'd like to use?", @@ -608,13 +613,17 @@ const validateTemplateSrcDirectory = (path: string, config: TemplateConfig) => { if (config.platform === "workers") { const wranglerTomlPath = resolve(path, "wrangler.toml"); if (!existsSync(wranglerTomlPath)) { - crash(`create-cloudflare templates must contain a "wrangler.toml" file.`); + throw new Error( + `create-cloudflare templates must contain a "wrangler.toml" file.`, + ); } } const pkgJsonPath = resolve(path, "package.json"); if (!existsSync(pkgJsonPath)) { - crash(`create-cloudflare templates must contain a "package.json" file.`); + throw new Error( + `create-cloudflare templates must contain a "package.json" file.`, + ); } }; @@ -674,7 +683,7 @@ export const downloadRemoteTemplate = async (src: string) => { return tmpDir; } catch (error) { updateStatus(`${brandColor("template")} ${dim("failed")}`); - return crash(`Failed to clone remote template: ${src}`); + throw new Error(`Failed to clone remote template: ${src}`); } }; diff --git a/packages/create-cloudflare/src/wrangler/__tests__/accounts.test.ts b/packages/create-cloudflare/src/wrangler/__tests__/accounts.test.ts index ced5e1fd3543..a2a2326b0610 100644 --- a/packages/create-cloudflare/src/wrangler/__tests__/accounts.test.ts +++ b/packages/create-cloudflare/src/wrangler/__tests__/accounts.test.ts @@ -1,6 +1,8 @@ import { mockPackageManager, mockSpinner } from "helpers/__tests__/mocks"; import { runCommand } from "helpers/command"; +import { hasSparrowSourceKey } from "helpers/sparrow"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import { createTestContext } from "../../__tests__/helpers"; import { isLoggedIn, listAccounts, wranglerLogin } from "../accounts"; const loggedInWhoamiOutput = ` @@ -29,14 +31,19 @@ Successfully logged in. `; vi.mock("helpers/command"); +vi.mock("helpers/sparrow"); vi.mock("which-pm-runs"); vi.mock("@cloudflare/cli/interactive"); describe("wrangler account helpers", () => { + const ctx = createTestContext(); + let spinner: ReturnType; beforeEach(() => { mockPackageManager("npm"); + vi.mocked(hasSparrowSourceKey).mockReturnValue(true); + spinner = mockSpinner(); }); @@ -46,7 +53,7 @@ describe("wrangler account helpers", () => { .mocked(runCommand) .mockReturnValueOnce(Promise.resolve(loggedInWhoamiOutput)); - const loggedIn = await wranglerLogin(); + const loggedIn = await wranglerLogin(ctx); expect(loggedIn).toBe(true); expect(mock).toHaveBeenCalledWith( @@ -67,7 +74,7 @@ describe("wrangler account helpers", () => { .mockReturnValueOnce(Promise.resolve(loggedOutWhoamiOutput)) .mockReturnValueOnce(Promise.resolve(loginSuccessOutput)); - const loggedIn = await wranglerLogin(); + const loggedIn = await wranglerLogin(ctx); expect(loggedIn).toBe(true); expect(mock).toHaveBeenCalledWith( @@ -88,7 +95,7 @@ describe("wrangler account helpers", () => { .mockReturnValueOnce(Promise.resolve(loggedOutWhoamiOutput)) .mockReturnValueOnce(Promise.resolve(loginDeniedOutput)); - const loggedIn = await wranglerLogin(); + const loggedIn = await wranglerLogin(ctx); expect(loggedIn).toBe(false); expect(mock).toHaveBeenCalledWith( diff --git a/packages/create-cloudflare/src/wrangler/accounts.ts b/packages/create-cloudflare/src/wrangler/accounts.ts index 8fdb55e107ef..82a6a5f1febb 100644 --- a/packages/create-cloudflare/src/wrangler/accounts.ts +++ b/packages/create-cloudflare/src/wrangler/accounts.ts @@ -2,6 +2,7 @@ import { brandColor, dim } from "@cloudflare/cli/colors"; import { inputPrompt, spinner } from "@cloudflare/cli/interactive"; import { runCommand } from "helpers/command"; import { detectPackageManager } from "helpers/packageManagers"; +import { reporter } from "../metrics"; import type { C3Context } from "types"; export const chooseAccount = async (ctx: C3Context) => { @@ -46,30 +47,47 @@ export const chooseAccount = async (ctx: C3Context) => { ctx.account = { id: accountId, name: accountName }; }; -export const wranglerLogin = async () => { - const { npx } = detectPackageManager(); - - const s = spinner(); - s.start(`Logging into Cloudflare ${dim("checking authentication status")}`); - const alreadyLoggedIn = await isLoggedIn(); - s.stop(brandColor(alreadyLoggedIn ? "logged in" : "not logged in")); - if (alreadyLoggedIn) { - return true; - } - - s.start(`Logging into Cloudflare ${dim("This will open a browser window")}`); - - // We're using a custom spinner since this is a little complicated. - // We want to vary the done status based on the output - const output = await runCommand([npx, "wrangler", "login"], { - silent: true, +export const wranglerLogin = async (ctx: C3Context) => { + return reporter.collectAsyncMetrics({ + eventPrefix: "c3 login", + props: { + args: ctx.args, + }, + async promise() { + const { npx } = detectPackageManager(); + + const s = spinner(); + s.start( + `Logging into Cloudflare ${dim("checking authentication status")}`, + ); + const isAlreadyLoggedIn = await isLoggedIn(); + s.stop(brandColor(isAlreadyLoggedIn ? "logged in" : "not logged in")); + + reporter.setEventProperty("isAlreadyLoggedIn", isAlreadyLoggedIn); + + if (isAlreadyLoggedIn) { + return true; + } + + s.start( + `Logging into Cloudflare ${dim("This will open a browser window")}`, + ); + + // We're using a custom spinner since this is a little complicated. + // We want to vary the done status based on the output + const output = await runCommand([npx, "wrangler", "login"], { + silent: true, + }); + const success = /Successfully logged in/.test(output); + + const verb = success ? "allowed" : "denied"; + s.stop(`${brandColor(verb)} ${dim("via `wrangler login`")}`); + + reporter.setEventProperty("isLoginSuccessful", success); + + return success; + }, }); - const success = /Successfully logged in/.test(output); - - const verb = success ? "allowed" : "denied"; - s.stop(`${brandColor(verb)} ${dim("via `wrangler login`")}`); - - return success; }; export const listAccounts = async () => { diff --git a/packages/create-cloudflare/telemetry.md b/packages/create-cloudflare/telemetry.md new file mode 100644 index 000000000000..508dd73bb34e --- /dev/null +++ b/packages/create-cloudflare/telemetry.md @@ -0,0 +1,95 @@ +# Create-Cloudflare Telemetry + +Cloudflare gathers non-user identifying telemetry data about usage of [create-cloudflare](https://www.npmjs.com/package/create-cloudflare), the command-line interface for scaffolding Workers and Pages applications + +You can [opt out of sharing telemetry data](#how-can-i-configure-create-cloudflare-telemetry) at any time. + +## Why are we collecting telemetry data? + +Create-Cloudflare Telemetry allows us to better identify roadblocks and bugs and gain visibility on usage of features across all users. It also helps us to add new features to create a better overall experience. We monitor this data to ensure Create-Cloudflare’s consistent growth, stability, usability and developer experience. + +- If certain errors are hit more frequently, those bug fixes will be prioritized in future releases +- If certain languages are used more frequently, we will add more templates in this language +- If certain templates are no longer used, they will be removed and replaced + +## What telemetry data is Cloudflare collecting? + +- Command used as the entrypoint into Create-Cloudflare (e.g. `npm create cloudflare@latest`, `npm create cloudflare –-template myrepo`) +- Package manager (e.g. npm, yarn) +- Create-Cloudflare version (e.g. create-cloudflare 10.8.1) +- Whether project is renamed +- Sanitized error information (e.g. error type, frequency) +- Whether instance is a first time Create-Cloudflare download +- Used template and language +- Experience outcome (e.g. deployed, created locally, or no project created) +- Total session duration (e.g. 30 seconds, etc.) +- General machine information such as OS Version, CPU architecture (e.g. macOS, x84) + +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: any usernames, raw error logs and stack traces, file names/paths and content of files, and environment variables. Data is never shared with third parties. + +## How can I view analytics code? + +To view what data is being collected while using Create-Cloudflare, provide the environment variable `CREATE_CLOUDFLARE_TELEMETRY_DEBUG=1` during invocation: + +`CREATE_CLOUDFLARE_TELEMETRY_DEBUG=1 npm create cloudflare` + +All events can be viewed at [./src/event.ts](./src/event.ts). 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. + +An example of an event sent to Cloudflare might look like: + +```json +{ + "event": "c3 session started", + "deviceId": "9fd5d422-99a1-4c7d-9666-ca3637927fa6", + "timestamp": 1726760778899, + "properties": { + "amplitude_session_id": 1726760778800, + "amplitude_event_id": 0, + "platform": "Mac OS", + "c3Version": "2.34.5", + "isFirstUsage": false, + "packageManager": "npm", + "args": { + "_": [], + "auto-update": false, + "autoUpdate": false, + "experimental": false, + "open": true, + "$0": "create-cloudflare", + "additionalArgs": [] + } + } +} +``` + +## How can I configure Create-Cloudflare telemetry? + +If you would like to disable telemetry, you can run: + +```sh +npm create cloudflare telemetry disable +``` + +Alternatively, you can set an environment variable: + +```sh +export CREATE_CLOUDFLARE_TELEMETRY_DISABLED=1 +``` + +If you would like to re-enable telemetry, you can run: + +```sh +npm create cloudflare telemetry enable +``` + +If you would like to check the status of Create-Cloudflare telemetry, you can run: + +```sh +npm create cloudflare telemetry status +``` diff --git a/packages/create-cloudflare/templates/next/c3.ts b/packages/create-cloudflare/templates/next/c3.ts index 44d0a3ef15e3..03a3dee77914 100644 --- a/packages/create-cloudflare/templates/next/c3.ts +++ b/packages/create-cloudflare/templates/next/c3.ts @@ -1,8 +1,7 @@ import { join } from "path"; -import { crash, updateStatus, warn } from "@cloudflare/cli"; -import { processArgument } from "@cloudflare/cli/args"; +import { updateStatus, warn } from "@cloudflare/cli"; import { brandColor, dim } from "@cloudflare/cli/colors"; -import { spinner } from "@cloudflare/cli/interactive"; +import { inputPrompt, spinner } from "@cloudflare/cli/interactive"; import { runFrameworkGenerator } from "frameworks/index"; import { copyFile, @@ -18,7 +17,7 @@ import { detectPackageManager } from "helpers/packageManagers"; import { installPackages } from "helpers/packages"; import { getTemplatePath } from "../../src/templates"; import type { TemplateConfig } from "../../src/templates"; -import type { C3Args, C3Context } from "types"; +import type { C3Context } from "types"; const { npm, npx } = detectPackageManager(); @@ -42,7 +41,7 @@ const generate = async (ctx: C3Context) => { // This should never happen to users, it is a check mostly so that // if the toml file is changed in a way that breaks the "KV Example" addition // the C3 Next.js e2e runs will fail with this - crash("Failed to properly generate the wrangler.toml file"); + throw new Error("Failed to properly generate the wrangler.toml file"); } writeFile(join(ctx.project.path, "wrangler.toml"), newTomlContent); @@ -89,7 +88,7 @@ const configure = async (ctx: C3Context) => { ]); if (!path) { - crash("Could not find the `/api` or `/app` directory"); + throw new Error("Could not find the `/api` or `/app` directory"); } const usesTs = usesTypescript(ctx); @@ -135,7 +134,7 @@ export const shouldInstallNextOnPagesEslintPlugin = async ( return false; } - return await processArgument(ctx.args, "eslint-plugin" as keyof C3Args, { + return await inputPrompt({ type: "confirm", question: "Do you want to use the next-on-pages eslint-plugin?", label: "eslint-plugin", diff --git a/packages/create-cloudflare/templates/pre-existing/c3.ts b/packages/create-cloudflare/templates/pre-existing/c3.ts index 5ec86534cd50..eda7cef28eea 100644 --- a/packages/create-cloudflare/templates/pre-existing/c3.ts +++ b/packages/create-cloudflare/templates/pre-existing/c3.ts @@ -1,8 +1,8 @@ import { cp, mkdtemp } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; -import { processArgument } from "@cloudflare/cli/args"; import { brandColor, dim } from "@cloudflare/cli/colors"; +import { processArgument } from "helpers/args"; import { runCommand } from "helpers/command"; import { detectPackageManager } from "helpers/packageManagers"; import { chooseAccount } from "../../src/wrangler/accounts"; @@ -14,7 +14,7 @@ export async function copyExistingWorkerFiles(ctx: C3Context) { await chooseAccount(ctx); if (ctx.args.existingScript === undefined) { - ctx.args.existingScript = await processArgument( + ctx.args.existingScript = await processArgument( ctx.args, "existingScript", { diff --git a/packages/create-cloudflare/templates/qwik/c3.ts b/packages/create-cloudflare/templates/qwik/c3.ts index a699c084407a..534a802ff08b 100644 --- a/packages/create-cloudflare/templates/qwik/c3.ts +++ b/packages/create-cloudflare/templates/qwik/c3.ts @@ -1,4 +1,4 @@ -import { crash, endSection } from "@cloudflare/cli"; +import { endSection } from "@cloudflare/cli"; import { brandColor } from "@cloudflare/cli/colors"; import { spinner } from "@cloudflare/cli/interactive"; import { runFrameworkGenerator } from "frameworks/index"; @@ -75,7 +75,7 @@ const addBindingsProxy = (ctx: C3Context) => { } if (configArgument.type !== "ObjectExpression") { - crash("Failed to update `vite.config.ts`"); + throw new Error("Failed to update `vite.config.ts`"); } // Add the `platform` object to the object diff --git a/packages/create-cloudflare/templates/react/c3.ts b/packages/create-cloudflare/templates/react/c3.ts index 583e146f0733..d1ba3a79400f 100644 --- a/packages/create-cloudflare/templates/react/c3.ts +++ b/packages/create-cloudflare/templates/react/c3.ts @@ -1,5 +1,5 @@ import { logRaw } from "@cloudflare/cli"; -import { processArgument } from "@cloudflare/cli/args"; +import { inputPrompt } from "@cloudflare/cli/interactive"; import { runFrameworkGenerator } from "frameworks/index"; import { detectPackageManager } from "helpers/packageManagers"; import type { TemplateConfig } from "../../src/templates"; @@ -8,7 +8,7 @@ import type { C3Context } from "types"; const { npm } = detectPackageManager(); const generate = async (ctx: C3Context) => { - const variant = await processArgument(ctx.args, "variant", { + const variant = await inputPrompt({ type: "select", question: "Select a variant:", label: "variant", diff --git a/packages/create-cloudflare/turbo.json b/packages/create-cloudflare/turbo.json index 68d761744e24..a3479eb8dd53 100644 --- a/packages/create-cloudflare/turbo.json +++ b/packages/create-cloudflare/turbo.json @@ -3,7 +3,15 @@ "extends": ["//"], "pipeline": { "build": { - "env": ["TEST_PM", "TEST_PM_VERSION", "npm_config_user_agent", "CI"], + "env": [ + "TEST_PM", + "TEST_PM_VERSION", + "npm_config_user_agent", + "CI", + "CREATE_CLOUDFLARE_TELEMETRY_DISABLED", + "CREATE_CLOUDFLARE_TELEMETRY_DEBUG", + "SPARROW_SOURCE_KEY" + ], "outputs": ["dist/**"] }, "test:e2e": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30b5ef9a0c08..cbf3ad4a1ff7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -929,6 +929,9 @@ importers: wrap-ansi: specifier: ^9.0.0 version: 9.0.0 + xdg-app-paths: + specifier: ^8.3.0 + version: 8.3.0 yargs: specifier: ^17.7.2 version: 17.7.2