diff --git a/docs/app/docs/[[...slug]]/page.tsx b/docs/app/docs/[[...slug]]/page.tsx index 8170476c..fe66c1ad 100644 --- a/docs/app/docs/[[...slug]]/page.tsx +++ b/docs/app/docs/[[...slug]]/page.tsx @@ -24,6 +24,7 @@ import { DividerText } from "@/components/divider-text"; import { APIMethod } from "@/components/api-method"; import { LLMCopyButton, ViewOptions } from "./page.client"; import { GenerateAppleJwt } from "@/components/generate-apple-jwt"; +import Telemetry from "@/components/mdx/telemetry"; const { AutoTypeTable } = createTypeTable(); @@ -125,6 +126,7 @@ export default async function Page({ iframe: (props) => ( ), + Telemetry, }} /> diff --git a/docs/components/mdx/telemetry.tsx b/docs/components/mdx/telemetry.tsx new file mode 100644 index 00000000..892d1e67 --- /dev/null +++ b/docs/components/mdx/telemetry.tsx @@ -0,0 +1,138 @@ +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +import { + Settings, + ShieldCheck, + Package2, + Terminal, + GitBranch, + Cpu, + CircleCheck, + Box, + Plug, + GitCommitHorizontal, + Globe, + MemoryStick, + CircuitBoard, + Brain, + ServerCog, + Container, + AppWindow, + TerminalSquare, + CirclePlay, +} from "lucide-react"; + +const telemetryPoints = [ + { + icon: GitCommitHorizontal, + label: "Anonymous Project ID", + title: "Unique project identifier, anonymized for privacy.", + }, + { + icon: ShieldCheck, + label: "Sanitized Config", + title: + "Auth configuration options passed into Better Auth, cleaned of sensitive info.", + }, + { icon: Plug, label: "Enabled Plugins", title: "List of active plugins." }, + { + icon: Settings, + label: "Better Auth Version", + title: "Current version of Better Auth.", + }, + { + icon: ServerCog, + label: "Database", + title: "Type and version of the database in use.", + }, + { + icon: Box, + label: "Framework", + title: "The framework powering the app and its version.", + }, + { + icon: Package2, + label: "Package Manager", + title: "The package manager in use and its version.", + }, + { + icon: Terminal, + label: "Runtime", + title: "The JavaScript runtime in use and its version.", + }, + { icon: Globe, label: "OS", title: "Operating system of the host machine." }, + { + icon: CircuitBoard, + label: "CPU Arch", + title: "Processor architecture type.", + }, + { icon: Cpu, label: "CPU Count", title: "Number of CPU cores available." }, + { icon: Brain, label: "CPU Model", title: "Model identifier of the CPU." }, + { + icon: MemoryStick, + label: "Total Memory", + title: "Total system memory (RAM) installed.", + }, + { + icon: GitBranch, + label: "isGit", + title: "Indicates if the project is version controlled by Git.", + }, + { + icon: CircleCheck, + label: "isProduction", + title: "Flag showing if running in production mode.", + }, + { + icon: CirclePlay, + label: "isCI", + title: + "Whether the code is running in a Continuous Integration environment.", + }, + { + icon: AppWindow, + label: "isWSL", + title: "True if running inside Windows Subsystem for Linux.", + }, + { + icon: Container, + label: "isDocker", + title: "True if running inside a Docker container.", + }, + { + icon: TerminalSquare, + label: "isTTY", + title: "True if running inside a TTY shell.", + }, +]; + +export default function Telemetry() { + return ( + + + {telemetryPoints.map(({ icon: Icon, label, title }, index) => ( + + + + + {label} + + + + {title} + + + ))} + + + ); +} diff --git a/docs/components/sidebar-content.tsx b/docs/components/sidebar-content.tsx index 8437a8c1..9332f903 100644 --- a/docs/components/sidebar-content.tsx +++ b/docs/components/sidebar-content.tsx @@ -1,4 +1,5 @@ import { + Binoculars, Book, CircleHelp, Database, @@ -1963,11 +1964,17 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, href: "/docs/reference/resources", icon: () => , }, + { title: "Security", href: "/docs/reference/security", icon: () => , }, + { + title: "Telemetry", + href: "/docs/reference/telemetry", + icon: () => , + }, { title: "FAQ", href: "/docs/reference/faq", @@ -1975,45 +1982,6 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, }, ], }, - // { - - // title: "Contribute", - // Icon: () => ( - // - // ), - // list: [ - // { - // title: "Getting Started", - // href: "/docs/contribute/getting-started", - // icon: () => , - // }, - // { - // title: "Areas to Contribute", - // href: "/docs/contribute/areas-to-contribute", - // icon: () => , - // }, - // // { - // // title: "Database Adapters", - // // href: "/docs/contribute/database-adapters", - // // icon: () => , - // // }, - // { - // title: "Testing", - // href: "/docs/contribute/testing", - // icon: () => , - // }, - // { - // title: "Documenting", - // href: "/docs/contribute/documenting", - // icon: () => , - // }, - // { - // title: "Security Issues", - // href: "/docs/contribute/security-issues", - // icon: () => , - // }, - // ], - // }, ]; export const examples: Content[] = [ diff --git a/docs/components/ui/tooltip.tsx b/docs/components/ui/tooltip.tsx index 17b986be..b603152b 100644 --- a/docs/components/ui/tooltip.tsx +++ b/docs/components/ui/tooltip.tsx @@ -52,7 +52,7 @@ function TooltipContent({ {...props} > {children} - + ); diff --git a/docs/content/docs/reference/options.mdx b/docs/content/docs/reference/options.mdx index a3715399..a37cd8c2 100644 --- a/docs/content/docs/reference/options.mdx +++ b/docs/content/docs/reference/options.mdx @@ -605,3 +605,16 @@ export const auth = betterAuth({ disabledPaths: ["/sign-up/email", "/sign-in/email"], }) ``` + +## `telemetry` + +Enable or disable Better Auth's telemetry collection. (default: `true`) + +```ts +import { betterAuth } from "better-auth"; +export const auth = betterAuth({ + telemetry: { + enabled: false, + } +}) +``` diff --git a/docs/content/docs/reference/telemetry.mdx b/docs/content/docs/reference/telemetry.mdx new file mode 100644 index 00000000..08d49a72 --- /dev/null +++ b/docs/content/docs/reference/telemetry.mdx @@ -0,0 +1,68 @@ +--- +title: Telemetry +description: Better Auth now collects anonymous telemetry data about general usage. +--- + +## Why is telemetry collected? + +Telemetry data helps us understand how Better Auth is being used across different environments so we can improve performance, prioritize features, and fix issues more effectively. Telemetry data helps us improve Better Auth by giving us insight into how it’s used in real-world environments. It guides our decisions on performance optimizations, feature development, and bug fixes. All data is collected anonymously and with privacy in mind, and users can opt out at any time. + +## What is being collected? + +The following data points may be reported. Everything is anonymous and intended for aggregate insights only. + +- **Anonymous identifier**: A non-reversible hash derived from your project (`package.json` name and optionally `baseURL`). This lets us de‑duplicate events per project without knowing who you are. +- **Runtime**: `{ name: "node" | "bun" | "deno", version }`. +- **Environment**: one of `development`, `production`, `test`, or `ci`. +- **Framework (if detected)**: `{ name, version }` for frameworks like Next.js, Nuxt, Remix, Astro, SvelteKit, etc. +- **Database (if detected)**: `{ name, version }` for integrations like PostgreSQL, MySQL, SQLite, Prisma, Drizzle, MongoDB, etc. +- **System info**: platform, OS release, architecture, CPU count/model/speed, total memory, and flags like `isDocker`, `isWSL`, `isTTY`. +- **Package manager**: `{ name, version }` derived from the npm user agent. +- **Redacted auth config snapshot**: A minimized, privacy‑preserving view of your `betterAuth` options produced by `getTelemetryAuthConfig`. + +We also collect anonymous telemetry from the CLI: + +- **CLI generate (`cli_generate`)**: outcome `generated | overwritten | appended | no_changes | aborted` plus redacted config. +- **CLI migrate (`cli_migrate`)**: outcome `migrated | no_changes | aborted | unsupported_adapter` plus adapter id (when relevant) and redacted config. + +You can audit telemetry locally by setting the `BETTER_AUTH_TELEMETRY_DEBUG=1` environment variable when running your project. In this debug mode, telemetry events are logged only to the console. + +## How is my data protected? + +All collected data is fully anonymous and only useful in aggregate. It cannot be traced back to any individual source and is accessible only to a small group of core Better Auth maintainers to guide roadmap decisions. + +- **No PII or secrets**: We do not collect emails, usernames, tokens, secrets, client IDs, client secrets, or database URLs. +- **No full config**: We never send your full `betterAuth` configuration. Instead we send a reduced, redacted snapshot of non‑sensitive toggles and counts. +- **Redaction by design**: See [detect-auth-config.ts](https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/telemetry/detectors/detect-auth-config.ts) in the Better Auth source for the exact shape of what is included. It purposely converts sensitive values to booleans, counts, or generic identifiers. + +## How can I disable it? + +You can disable telemetry collection in your auth config or by setting an environment variable. + +- Via your auth config. + + ```ts title="auth.ts" + export const auth = betterAuth({ + // [!code highlight] + telemetry: { // [!code highlight] + enabled: false // [!code highlight] + } // [!code highlight] + }); + ``` + +- Via an environment variable. + + ```env title=".env" + # Enable telemetry + BETTER_AUTH_TELEMETRY=1 + + # Disable telemetry + BETTER_AUTH_TELEMETRY=0 + ``` + +### When is telemetry sent? + +- On `betterAuth` initialization (`type: "init"`). +- On CLI actions: `generate` and `migrate` as described above. + +Telemetry is disabled automatically in tests (`NODE_ENV=test`) unless explicitly overridden by internal tooling. diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index 55988201..84c7e98f 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -733,6 +733,7 @@ "solid-js": "^1.9.3", "tarn": "^3.0.2", "tedious": "^18.6.1", + "type-fest": "^4.41.0", "typescript": "catalog:", "unbuild": "catalog:", "vue": "^3.5.13", diff --git a/packages/better-auth/src/__snapshots__/init.test.ts.snap b/packages/better-auth/src/__snapshots__/init.test.ts.snap index 046ee55c..6f35457b 100644 --- a/packages/better-auth/src/__snapshots__/init.test.ts.snap +++ b/packages/better-auth/src/__snapshots__/init.test.ts.snap @@ -127,6 +127,7 @@ exports[`init > should match config 1`] = ` "hash": [Function], "verify": [Function], }, + "publishTelemetry": [Function], "rateLimit": { "enabled": false, "max": 100, diff --git a/packages/better-auth/src/adapters/kysely-adapter/dialect.ts b/packages/better-auth/src/adapters/kysely-adapter/dialect.ts index 7398089e..e51d1581 100644 --- a/packages/better-auth/src/adapters/kysely-adapter/dialect.ts +++ b/packages/better-auth/src/adapters/kysely-adapter/dialect.ts @@ -8,14 +8,14 @@ import { import type { BetterAuthOptions } from "../../types"; import type { KyselyDatabaseType } from "./types"; -function getDatabaseType( +export function getKyselyDatabaseType( db: BetterAuthOptions["database"], ): KyselyDatabaseType | null { if (!db) { return null; } if ("dialect" in db) { - return getDatabaseType(db.dialect as Dialect); + return getKyselyDatabaseType(db.dialect as Dialect); } if ("createDriver" in db) { if (db instanceof SqliteDialect) { @@ -73,7 +73,7 @@ export const createKyselyAdapter = async (config: BetterAuthOptions) => { let dialect: Dialect | undefined = undefined; - const databaseType = getDatabaseType(db); + const databaseType = getKyselyDatabaseType(db); if ("createDriver" in db) { dialect = db; diff --git a/packages/better-auth/src/index.ts b/packages/better-auth/src/index.ts index cd7267c8..aa6a2c71 100644 --- a/packages/better-auth/src/index.ts +++ b/packages/better-auth/src/index.ts @@ -10,3 +10,8 @@ export type * from "zod/v4/core"; export type * from "./types/helper"; // export this as we are referencing OAuth2Tokens in the `refresh-token` api as return type export type * from "./oauth2/types"; + +// telemetry exports for CLI and consumers +export { createTelemetry } from "./telemetry"; +export { getTelemetryAuthConfig } from "./telemetry/detectors/detect-auth-config"; +export type { TelemetryEvent } from "./telemetry/types"; diff --git a/packages/better-auth/src/init.ts b/packages/better-auth/src/init.ts index c6198a00..daa74738 100644 --- a/packages/better-auth/src/init.ts +++ b/packages/better-auth/src/init.ts @@ -27,13 +27,15 @@ import { checkPassword } from "./utils/password"; import { getBaseURL } from "./utils/url"; import type { LiteralUnion } from "./types/helper"; import { BetterAuthError } from "./error"; +import { createTelemetry } from "./telemetry"; +import type { TelemetryEvent } from "./telemetry/types"; +import { getKyselyDatabaseType } from "./adapters/kysely-adapter"; export const init = async (options: BetterAuthOptions) => { const adapter = await getAdapter(options); const plugins = options.plugins || []; const internalPlugins = getInternalPlugins(options); const logger = createLogger(options.logger); - const baseURL = getBaseURL(options.baseURL, options.basePath); const secret = @@ -57,6 +59,7 @@ export const init = async (options: BetterAuthOptions) => { basePath: options.basePath || "/api/auth", plugins: plugins.concat(internalPlugins), }; + const cookies = getCookies(options); const tables = getAuthTables(options); const providers = Object.keys(options.socialProviders || {}) @@ -91,7 +94,15 @@ export const init = async (options: BetterAuthOptions) => { return generateId(size); }; - const ctx: AuthContext = { + const { publish } = await createTelemetry(options, { + adapter: adapter.id, + database: + typeof options.database === "function" + ? "adapter" + : getKyselyDatabaseType(options.database) || "unknown", + }); + + let ctx: AuthContext = { appName: options.appName || "Better Auth", socialProviders: providers, options, @@ -120,7 +131,7 @@ export const init = async (options: BetterAuthOptions) => { (options.secondaryStorage ? "secondary-storage" : "memory"), }, authCookies: cookies, - logger: logger, + logger, generateId: generateIdFunc, session: null, secondaryStorage: options.secondaryStorage, @@ -155,6 +166,7 @@ export const init = async (options: BetterAuthOptions) => { const { runMigrations } = await getMigrations(options); await runMigrations(); }, + publishTelemetry: publish, }; let { context } = runPluginInit(ctx); return context; @@ -219,6 +231,7 @@ export type AuthContext = { }; tables: ReturnType; runMigrations: () => Promise; + publishTelemetry: (event: TelemetryEvent) => Promise; }; function runPluginInit(ctx: AuthContext) { diff --git a/packages/better-auth/src/telemetry/README.md b/packages/better-auth/src/telemetry/README.md new file mode 100644 index 00000000..e69de29b diff --git a/packages/better-auth/src/telemetry/detectors/detect-auth-config.ts b/packages/better-auth/src/telemetry/detectors/detect-auth-config.ts new file mode 100644 index 00000000..0d14c186 --- /dev/null +++ b/packages/better-auth/src/telemetry/detectors/detect-auth-config.ts @@ -0,0 +1,200 @@ +import type { SocialProviders } from "../../social-providers"; +import type { BetterAuthOptions } from "../../types"; +import type { TelemetryContext } from "../types"; + +export function getTelemetryAuthConfig( + options: BetterAuthOptions, + context?: TelemetryContext, +) { + return { + database: context?.database, + adapter: context?.adapter, + emailVerification: { + sendVerificationEmail: !!options.emailVerification?.sendVerificationEmail, + sendOnSignUp: !!options.emailVerification?.sendOnSignUp, + sendOnSignIn: !!options.emailVerification?.sendOnSignIn, + autoSignInAfterVerification: + !!options.emailVerification?.autoSignInAfterVerification, + expiresIn: options.emailVerification?.expiresIn, + onEmailVerification: !!options.emailVerification?.onEmailVerification, + afterEmailVerification: + !!options.emailVerification?.afterEmailVerification, + }, + emailAndPassword: { + enabled: !!options.emailAndPassword?.enabled, + disableSignUp: !!options.emailAndPassword?.disableSignUp, + requireEmailVerification: + !!options.emailAndPassword?.requireEmailVerification, + maxPasswordLength: options.emailAndPassword?.maxPasswordLength, + minPasswordLength: options.emailAndPassword?.minPasswordLength, + sendResetPassword: !!options.emailAndPassword?.sendResetPassword, + resetPasswordTokenExpiresIn: + options.emailAndPassword?.resetPasswordTokenExpiresIn, + onPasswordReset: !!options.emailAndPassword?.onPasswordReset, + password: { + hash: !!options.emailAndPassword?.password?.hash, + verify: !!options.emailAndPassword?.password?.verify, + }, + autoSignIn: !!options.emailAndPassword?.autoSignIn, + revokeSessionsOnPasswordReset: + !!options.emailAndPassword?.revokeSessionsOnPasswordReset, + }, + socialProviders: Object.keys(options.socialProviders || {}).map((p) => { + const provider = options.socialProviders?.[p as keyof SocialProviders]; + if (!provider) return {}; + return { + id: p, + mapProfileToUser: !!provider.mapProfileToUser, + disableDefaultScope: !!provider.disableDefaultScope, + disableIdTokenSignIn: !!provider.disableIdTokenSignIn, + disableImplicitSignUp: provider.disableImplicitSignUp, + disableSignUp: provider.disableSignUp, + getUserInfo: !!provider.getUserInfo, + overrideUserInfoOnSignIn: !!provider.overrideUserInfoOnSignIn, + prompt: provider.prompt, + verifyIdToken: !!provider.verifyIdToken, + scope: provider.scope, + refreshAccessToken: !!provider.refreshAccessToken, + }; + }), + plugins: options.plugins?.map((p) => p.id.toString()), + user: { + modelName: options.user?.modelName, + fields: options.user?.fields, + additionalFields: options.user?.additionalFields, + changeEmail: { + enabled: options.user?.changeEmail?.enabled, + sendChangeEmailVerification: + !!options.user?.changeEmail?.sendChangeEmailVerification, + }, + }, + verification: { + modelName: options.verification?.modelName, + disableCleanup: options.verification?.disableCleanup, + fields: options.verification?.fields, + }, + session: { + modelName: options.session?.modelName, + additionalFields: options.session?.additionalFields, + cookieCache: { + enabled: options.session?.cookieCache?.enabled, + maxAge: options.session?.cookieCache?.maxAge, + }, + disableSessionRefresh: options.session?.disableSessionRefresh, + expiresIn: options.session?.expiresIn, + fields: options.session?.fields, + freshAge: options.session?.freshAge, + preserveSessionInDatabase: options.session?.preserveSessionInDatabase, + storeSessionInDatabase: options.session?.storeSessionInDatabase, + updateAge: options.session?.updateAge, + }, + account: { + modelName: options.account?.modelName, + fields: options.account?.fields, + encryptOAuthTokens: options.account?.encryptOAuthTokens, + updateAccountOnSignIn: options.account?.updateAccountOnSignIn, + accountLinking: { + enabled: options.account?.accountLinking?.enabled, + trustedProviders: options.account?.accountLinking?.trustedProviders, + updateUserInfoOnLink: + options.account?.accountLinking?.updateUserInfoOnLink, + allowUnlinkingAll: options.account?.accountLinking?.allowUnlinkingAll, + }, + }, + hooks: { + after: !!options.hooks?.after, + before: !!options.hooks?.before, + }, + secondaryStorage: !!options.secondaryStorage, + advanced: { + cookiePrefix: !!options.advanced?.cookiePrefix, //this shouldn't be tracked + cookies: !!options.advanced?.cookies, + crossSubDomainCookies: { + domain: !!options.advanced?.crossSubDomainCookies?.domain, + enabled: options.advanced?.crossSubDomainCookies?.enabled, + additionalCookies: + options.advanced?.crossSubDomainCookies?.additionalCookies, + }, + database: { + useNumberId: !!options.advanced?.database?.useNumberId, + generateId: options.advanced?.database?.generateId, + defaultFindManyLimit: options.advanced?.database?.defaultFindManyLimit, + }, + useSecureCookies: options.advanced?.useSecureCookies, + ipAddress: { + disableIpTracking: options.advanced?.ipAddress?.disableIpTracking, + ipAddressHeaders: options.advanced?.ipAddress?.ipAddressHeaders, + }, + disableCSRFCheck: options.advanced?.disableCSRFCheck, + cookieAttributes: { + expires: options.advanced?.defaultCookieAttributes?.expires, + secure: options.advanced?.defaultCookieAttributes?.secure, + sameSite: options.advanced?.defaultCookieAttributes?.sameSite, + domain: !!options.advanced?.defaultCookieAttributes?.domain, + path: options.advanced?.defaultCookieAttributes?.path, + httpOnly: options.advanced?.defaultCookieAttributes?.httpOnly, + }, + }, + trustedOrigins: options.trustedOrigins?.length, + rateLimit: { + storage: options.rateLimit?.storage, + modelName: options.rateLimit?.modelName, + window: options.rateLimit?.window, + customStorage: !!options.rateLimit?.customStorage, + enabled: options.rateLimit?.enabled, + max: options.rateLimit?.max, + }, + onAPIError: { + errorURL: options.onAPIError?.errorURL, + onError: !!options.onAPIError?.onError, + throw: options.onAPIError?.throw, + }, + logger: { + disabled: options.logger?.disabled, + level: options.logger?.level, + log: !!options.logger?.log, + }, + databaseHooks: { + user: { + create: { + after: !!options.databaseHooks?.user?.create?.after, + before: !!options.databaseHooks?.user?.create?.before, + }, + update: { + after: !!options.databaseHooks?.user?.update?.after, + before: !!options.databaseHooks?.user?.update?.before, + }, + }, + session: { + create: { + after: !!options.databaseHooks?.session?.create?.after, + before: !!options.databaseHooks?.session?.create?.before, + }, + update: { + after: !!options.databaseHooks?.session?.update?.after, + before: !!options.databaseHooks?.session?.update?.before, + }, + }, + account: { + create: { + after: !!options.databaseHooks?.account?.create?.after, + before: !!options.databaseHooks?.account?.create?.before, + }, + update: { + after: !!options.databaseHooks?.account?.update?.after, + before: !!options.databaseHooks?.account?.update?.before, + }, + }, + verification: { + create: { + after: !!options.databaseHooks?.verification?.create?.after, + before: !!options.databaseHooks?.verification?.create?.before, + }, + update: { + after: !!options.databaseHooks?.verification?.update?.after, + before: !!options.databaseHooks?.verification?.update?.before, + }, + }, + }, + }; +} diff --git a/packages/better-auth/src/telemetry/detectors/detect-database.ts b/packages/better-auth/src/telemetry/detectors/detect-database.ts new file mode 100644 index 00000000..e7466453 --- /dev/null +++ b/packages/better-auth/src/telemetry/detectors/detect-database.ts @@ -0,0 +1,22 @@ +import { getPackageVersion } from "../../utils/package-json"; +import type { DetectionInfo } from "../types"; + +const DATABASES: Record = { + pg: "postgresql", + mysql: "mysql", + mariadb: "mariadb", + sqlite3: "sqlite", + "better-sqlite3": "sqlite", + "@prisma/client": "prisma", + mongoose: "mongodb", + mongodb: "mongodb", + "drizzle-orm": "drizzle", +}; + +export async function detectDatabase(): Promise { + for (const [pkg, name] of Object.entries(DATABASES)) { + const version = await getPackageVersion(pkg); + if (version) return { name, version }; + } + return undefined; +} diff --git a/packages/better-auth/src/telemetry/detectors/detect-framework.ts b/packages/better-auth/src/telemetry/detectors/detect-framework.ts new file mode 100644 index 00000000..54b113ed --- /dev/null +++ b/packages/better-auth/src/telemetry/detectors/detect-framework.ts @@ -0,0 +1,23 @@ +import { getPackageVersion } from "../../utils/package-json"; + +const FRAMEWORKS: Record = { + next: "next", + nuxt: "nuxt", + "@remix-run/server-runtime": "remix", + astro: "astro", + "@sveltejs/kit": "sveltekit", + "solid-start": "solid-start", + "tanstack-start": "tanstack-start", + hono: "hono", + express: "express", + elysia: "elysia", + expo: "expo", +}; + +export async function detectFramework() { + for (const [pkg, name] of Object.entries(FRAMEWORKS)) { + const version = await getPackageVersion(pkg); + if (version) return { name, version }; + } + return undefined; +} diff --git a/packages/better-auth/src/telemetry/detectors/detect-project-info.ts b/packages/better-auth/src/telemetry/detectors/detect-project-info.ts new file mode 100644 index 00000000..ebdbc697 --- /dev/null +++ b/packages/better-auth/src/telemetry/detectors/detect-project-info.ts @@ -0,0 +1,17 @@ +// https://github.com/zkochan/packages/blob/main/which-pm-runs/index.js +export function detectPackageManager() { + const userAgent = process.env.npm_config_user_agent; + + if (!userAgent) { + return undefined; + } + + const pmSpec = userAgent.split(" ")[0]; + const separatorPos = pmSpec.lastIndexOf("/"); + const name = pmSpec.substring(0, separatorPos); + + return { + name: name === "npminstall" ? "cnpm" : name, + version: pmSpec.substring(separatorPos + 1), + }; +} diff --git a/packages/better-auth/src/telemetry/detectors/detect-runtime.ts b/packages/better-auth/src/telemetry/detectors/detect-runtime.ts new file mode 100644 index 00000000..6c3ec8b8 --- /dev/null +++ b/packages/better-auth/src/telemetry/detectors/detect-runtime.ts @@ -0,0 +1,28 @@ +import { getEnvVar, isTest } from "../../utils/env"; +import { isCI } from "./detect-system-info"; + +export function detectRuntime() { + // @ts-expect-error: TS doesn't know about Deno global + if (typeof Deno !== "undefined") { + // @ts-expect-error: TS doesn't know about Deno global + const denoVersion = Deno?.version?.deno ?? null; + return { name: "deno", version: denoVersion }; + } + + if (typeof Bun !== "undefined") { + const bunVersion = Bun?.version ?? null; + return { name: "bun", version: bunVersion }; + } + + return { name: "node", version: process.versions.node ?? null }; +} + +export function detectEnvironment() { + return getEnvVar("NODE_ENV") === "production" + ? "production" + : isCI() + ? "ci" + : isTest() + ? "test" + : "development"; +} diff --git a/packages/better-auth/src/telemetry/detectors/detect-system-info.ts b/packages/better-auth/src/telemetry/detectors/detect-system-info.ts new file mode 100644 index 00000000..dc938911 --- /dev/null +++ b/packages/better-auth/src/telemetry/detectors/detect-system-info.ts @@ -0,0 +1,106 @@ +import fs from "fs"; +import os from "os"; +import process from "process"; +import { env } from "../../utils/env"; + +export function detectSystemInfo() { + const cpus = os.cpus(); + return { + systemPlatform: os.platform(), + systemRelease: os.release(), + systemArchitecture: os.arch(), + cpuCount: cpus.length, + cpuModel: cpus.length ? cpus[0].model : null, + cpuSpeed: cpus.length ? cpus[0].speed : null, + memory: os.totalmem(), + isWSL: isWsl(), + isDocker: isDocker(), + isTTY: process.stdout.isTTY, + }; +} + +let isDockerCached: boolean | undefined; + +function hasDockerEnv() { + try { + fs.statSync("/.dockerenv"); + return true; + } catch { + return false; + } +} + +function hasDockerCGroup() { + try { + return fs.readFileSync("/proc/self/cgroup", "utf8").includes("docker"); + } catch { + return false; + } +} + +function isDocker() { + if (isDockerCached === undefined) { + isDockerCached = hasDockerEnv() || hasDockerCGroup(); + } + + return isDockerCached; +} + +function isWsl() { + if (process.platform !== "linux") { + return false; + } + + if (os.release().toLowerCase().includes("microsoft")) { + if (isInsideContainer()) { + return false; + } + + return true; + } + + try { + return fs + .readFileSync("/proc/version", "utf8") + .toLowerCase() + .includes("microsoft") + ? !isInsideContainer() + : false; + } catch { + return false; + } +} + +let isInsideContainerCached: boolean | undefined; + +const hasContainerEnv = () => { + try { + fs.statSync("/run/.containerenv"); + return true; + } catch { + return false; + } +}; + +function isInsideContainer() { + if (isInsideContainerCached === undefined) { + isInsideContainerCached = hasContainerEnv() || isDocker(); + } + + return isInsideContainerCached; +} + +export function isCI() { + return ( + env.CI !== "false" && + ("BUILD_ID" in env || // Jenkins, Cloudbees + "BUILD_NUMBER" in env || // Jenkins, TeamCity (fixed typo: extra space removed) + "CI" in env || // Travis CI, CircleCI, Cirrus CI, Gitlab CI, Appveyor, CodeShip, dsari, Cloudflare + "CI_APP_ID" in env || // Appflow + "CI_BUILD_ID" in env || // Appflow + "CI_BUILD_NUMBER" in env || // Appflow + "CI_NAME" in env || // Codeship and others + "CONTINUOUS_INTEGRATION" in env || // Travis CI, Cirrus CI + "RUN_ID" in env) // TaskCluster, dsari + ); +} diff --git a/packages/better-auth/src/telemetry/index.ts b/packages/better-auth/src/telemetry/index.ts new file mode 100644 index 00000000..6d455664 --- /dev/null +++ b/packages/better-auth/src/telemetry/index.ts @@ -0,0 +1,76 @@ +import { ENV, getBooleanEnvVar, isTest } from "../utils/env"; +import { getProjectId } from "./project-id"; +import type { BetterAuthOptions } from "../types"; +import { detectEnvironment, detectRuntime } from "./detectors/detect-runtime"; +import { detectDatabase } from "./detectors/detect-database"; +import { detectFramework } from "./detectors/detect-framework"; +import { detectSystemInfo } from "./detectors/detect-system-info"; +import { detectPackageManager } from "./detectors/detect-project-info"; +import { betterFetch } from "@better-fetch/fetch"; +import type { TelemetryContext, TelemetryEvent } from "./types"; +import { logger } from "../utils"; +import { getTelemetryAuthConfig } from "./detectors/detect-auth-config"; + +export async function createTelemetry( + options: BetterAuthOptions, + context?: TelemetryContext, +) { + const debugEnabled = getBooleanEnvVar("BETTER_AUTH_TELEMETRY_DEBUG", false); + const TELEMETRY_ENDPOINT = ENV.BETTER_AUTH_TELEMETRY_ENDPOINT; + const track = async (event: TelemetryEvent) => { + try { + if (context?.customTrack) { + await context.customTrack(event); + } else { + if (debugEnabled) { + await Promise.resolve( + logger.info("telemetry event", JSON.stringify(event, null, 2)), + ); + } else { + await betterFetch(TELEMETRY_ENDPOINT, { + method: "POST", + body: event, + }); + } + } + } catch {} + }; + + const isEnabled = async () => { + const telemetryEnabled = + options.telemetry?.enabled !== undefined + ? options.telemetry.enabled + : true; + const envEnabled = getBooleanEnvVar("BETTER_AUTH_TELEMETRY", true); + return ( + envEnabled && telemetryEnabled && (context?.skipTestCheck || !isTest()) + ); + }; + + const anonymousId = await getProjectId(options.baseURL); + + const payload = { + config: getTelemetryAuthConfig(options), + runtime: detectRuntime(), + database: await detectDatabase(), + framework: await detectFramework(), + environment: detectEnvironment(), + systemInfo: detectSystemInfo(), + packageManager: detectPackageManager(), + }; + const enabled = await isEnabled(); + if (enabled) { + void track({ type: "init", payload, anonymousId }); + } + + return { + publish: async (event: TelemetryEvent) => { + if (!enabled) return; + await track({ + type: event.type, + payload: event.payload, + anonymousId, + }); + }, + }; +} diff --git a/packages/better-auth/src/telemetry/project-id.ts b/packages/better-auth/src/telemetry/project-id.ts new file mode 100644 index 00000000..a86ae81a --- /dev/null +++ b/packages/better-auth/src/telemetry/project-id.ts @@ -0,0 +1,27 @@ +import { generateId } from "../utils"; +import { hashToBase64 } from "../crypto"; +import { getNameFromLocalPackageJson } from "../utils/package-json"; + +let projectIdCached: string | null = null; + +export async function getProjectId( + baseUrl: string | undefined, +): Promise { + if (projectIdCached) return projectIdCached; + + const projectName = await getNameFromLocalPackageJson(); + if (projectName) { + projectIdCached = await hashToBase64( + baseUrl ? baseUrl + projectName : projectName, + ); + return projectIdCached; + } + + if (baseUrl) { + projectIdCached = await hashToBase64(baseUrl); + return projectIdCached; + } + + projectIdCached = generateId(32); + return projectIdCached; +} diff --git a/packages/better-auth/src/telemetry/telemetry.test.ts b/packages/better-auth/src/telemetry/telemetry.test.ts new file mode 100644 index 00000000..58d79218 --- /dev/null +++ b/packages/better-auth/src/telemetry/telemetry.test.ts @@ -0,0 +1,298 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createTelemetry } from "./index"; +import type { TelemetryEvent } from "./types"; + +vi.mock("@better-fetch/fetch", () => ({ + betterFetch: vi.fn(async () => ({ status: 200 })), +})); + +vi.mock("./project-id", () => ({ + getProjectId: vi.fn(async () => "anon-123"), +})); + +vi.mock("./detectors/detect-runtime", () => ({ + detectRuntime: vi.fn(() => ({ name: "node", version: "test" })), + detectEnvironment: vi.fn(() => "test"), +})); + +vi.mock("./detectors/detect-database", () => ({ + detectDatabase: vi.fn(async () => ({ name: "postgresql", version: "1.0.0" })), +})); + +vi.mock("./detectors/detect-framework", () => ({ + detectFramework: vi.fn(async () => ({ name: "next", version: "15.0.0" })), +})); + +vi.mock("./detectors/detect-system-info", () => ({ + detectSystemInfo: vi.fn(() => ({ + systemPlatform: "darwin", + systemRelease: "24.6.0", + systemArchitecture: "arm64", + cpuCount: 8, + cpuModel: "Apple M3", + cpuSpeed: 3200, + memory: 16 * 1024 * 1024 * 1024, + isDocker: false, + isTTY: true, + isWSL: false, + isCI: false, + })), + isCI: vi.fn(() => false), +})); + +vi.mock("./detectors/detect-project-info", () => ({ + detectPackageManager: vi.fn(() => ({ name: "pnpm", version: "9.0.0" })), +})); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + process.env.BETTER_AUTH_TELEMETRY = ""; + process.env.BETTER_AUTH_TELEMETRY_DEBUG = ""; +}); + +describe("telemetry", () => { + it("publishes events when enabled", async () => { + let event: TelemetryEvent | undefined; + const track = vi.fn().mockImplementation(async (e) => { + event = e; + }); + await createTelemetry( + { + baseURL: "http://localhost.com", //this shouldn't be tracked + appName: "test", //this shouldn't be tracked + advanced: { + cookiePrefix: "test", //this shouldn't be tracked - should set to true + crossSubDomainCookies: { + domain: ".test.com", //this shouldn't be tracked - should set to true + enabled: true, + }, + }, + }, + { customTrack: track, skipTestCheck: true }, + ); + expect(event).toMatchObject({ + type: "init", + payload: { + config: { + emailVerification: { + sendVerificationEmail: false, + sendOnSignUp: false, + sendOnSignIn: false, + autoSignInAfterVerification: false, + expiresIn: undefined, + onEmailVerification: false, + afterEmailVerification: false, + }, + emailAndPassword: { + enabled: false, + disableSignUp: false, + requireEmailVerification: false, + maxPasswordLength: undefined, + minPasswordLength: undefined, + sendResetPassword: false, + resetPasswordTokenExpiresIn: undefined, + onPasswordReset: false, + password: { hash: false, verify: false }, + autoSignIn: false, + revokeSessionsOnPasswordReset: false, + }, + socialProviders: [], + plugins: undefined, + user: { + modelName: undefined, + fields: undefined, + additionalFields: undefined, + changeEmail: { + enabled: undefined, + sendChangeEmailVerification: false, + }, + }, + verification: { + modelName: undefined, + disableCleanup: undefined, + fields: undefined, + }, + session: { + modelName: undefined, + additionalFields: undefined, + cookieCache: { enabled: undefined, maxAge: undefined }, + disableSessionRefresh: undefined, + expiresIn: undefined, + fields: undefined, + freshAge: undefined, + preserveSessionInDatabase: undefined, + storeSessionInDatabase: undefined, + updateAge: undefined, + }, + account: { + modelName: undefined, + fields: undefined, + encryptOAuthTokens: undefined, + updateAccountOnSignIn: undefined, + accountLinking: { + enabled: undefined, + trustedProviders: undefined, + updateUserInfoOnLink: undefined, + allowUnlinkingAll: undefined, + }, + }, + hooks: { after: false, before: false }, + secondaryStorage: false, + advanced: { + cookiePrefix: true, + cookies: false, + crossSubDomainCookies: { + domain: true, + enabled: true, + additionalCookies: undefined, + }, + database: { + useNumberId: false, + generateId: undefined, + defaultFindManyLimit: undefined, + }, + useSecureCookies: undefined, + ipAddress: { + disableIpTracking: undefined, + ipAddressHeaders: undefined, + }, + disableCSRFCheck: undefined, + cookieAttributes: { + expires: undefined, + secure: undefined, + sameSite: undefined, + domain: false, + path: undefined, + httpOnly: undefined, + }, + }, + trustedOrigins: undefined, + rateLimit: { + storage: undefined, + modelName: undefined, + window: undefined, + customStorage: false, + enabled: undefined, + max: undefined, + }, + onAPIError: { + errorURL: undefined, + onError: false, + throw: undefined, + }, + logger: { disabled: undefined, level: undefined, log: false }, + databaseHooks: { + user: { + create: { + after: false, + before: false, + }, + update: { + after: false, + before: false, + }, + }, + session: { + create: { + after: false, + before: false, + }, + update: { + after: false, + before: false, + }, + }, + account: { + create: { + after: false, + before: false, + }, + update: { + after: false, + before: false, + }, + }, + verification: { + create: { + after: false, + before: false, + }, + update: { + after: false, + before: false, + }, + }, + }, + }, + runtime: { name: "node", version: "test" }, + database: { name: "postgresql", version: "1.0.0" }, + framework: { name: "next", version: "15.0.0" }, + environment: "test", + systemInfo: { + systemPlatform: "darwin", + systemRelease: "24.6.0", + systemArchitecture: "arm64", + cpuCount: 8, + cpuModel: "Apple M3", + cpuSpeed: 3200, + memory: 17179869184, + isDocker: false, + isTTY: true, + isWSL: false, + isCI: false, + }, + packageManager: { name: "pnpm", version: "9.0.0" }, + }, + anonymousId: "anon-123", + }); + }); + + it("does not publish when disabled via env", async () => { + process.env.BETTER_AUTH_TELEMETRY = "false"; + let event: TelemetryEvent | undefined; + const track = vi.fn().mockImplementation(async (e) => { + event = e; + }); + await createTelemetry( + { + baseURL: "http://localhost", + }, + { customTrack: track, skipTestCheck: true }, + ); + expect(event).toBeUndefined(); + expect(track).not.toBeCalled(); + }); + + it("does not publish when disabled via option", async () => { + let event: TelemetryEvent | undefined; + const track = vi.fn().mockImplementation(async (e) => { + event = e; + }); + await createTelemetry( + { + baseURL: "http://localhost", + telemetry: { enabled: false }, + }, + { customTrack: track, skipTestCheck: true }, + ); + expect(event).toBeUndefined(); + expect(track).not.toBeCalled(); + }); + + it("shouldn't fail cause track isn't being reached", async () => { + await expect( + createTelemetry( + { + baseURL: "http://localhost", + }, + { + customTrack() { + throw new Error("test"); + }, + skipTestCheck: true, + }, + ), + ).resolves.not.throw(Error); + }); +}); diff --git a/packages/better-auth/src/telemetry/types.ts b/packages/better-auth/src/telemetry/types.ts new file mode 100644 index 00000000..38134b76 --- /dev/null +++ b/packages/better-auth/src/telemetry/types.ts @@ -0,0 +1,46 @@ +export interface DetectionInfo { + name: string; + version: string | null; +} + +export interface SystemInfo { + // Software information + systemPlatform: string; + systemRelease: string; + systemArchitecture: string; + + // Machine information + cpuCount: number; + cpuModel: string | null; + cpuSpeed: number | null; + memory: number; + + // Environment information + isDocker: boolean; + isTTY: boolean; + isWSL: boolean; + isCI: boolean; +} + +export interface AuthConfigInfo { + options: any; + plugins: string[]; +} + +export interface ProjectInfo { + isGit: boolean; + packageManager: DetectionInfo | null; +} + +export interface TelemetryEvent { + type: string; + anonymousId?: string; + payload: Record; +} + +export interface TelemetryContext { + customTrack?: (event: TelemetryEvent) => Promise; + database?: string; + adapter?: string; + skipTestCheck?: boolean; +} diff --git a/packages/better-auth/src/types/options.ts b/packages/better-auth/src/types/options.ts index 6e9ca536..4887839c 100644 --- a/packages/better-auth/src/types/options.ts +++ b/packages/better-auth/src/types/options.ts @@ -173,7 +173,6 @@ export type BetterAuthOptions = { * Auto signin the user after they verify their email */ autoSignInAfterVerification?: boolean; - /** * Number of seconds the verification token is * valid for. @@ -1046,4 +1045,15 @@ export type BetterAuthOptions = { * Paths you want to disable. */ disabledPaths?: string[]; + /** + * Telemetry configuration + */ + telemetry?: { + /** + * Enable telemetry collection + * + * @default true + */ + enabled?: boolean; + }; }; diff --git a/packages/better-auth/src/utils/await-object.ts b/packages/better-auth/src/utils/await-object.ts new file mode 100644 index 00000000..7dbb1b15 --- /dev/null +++ b/packages/better-auth/src/utils/await-object.ts @@ -0,0 +1,13 @@ +export async function awaitObject>>( + promises: T, +): Promise<{ [K in keyof T]: Awaited }> { + const entries = Object.entries(promises) as [keyof T, T[keyof T]][]; + const results = await Promise.all(entries.map(([, promise]) => promise)); + + const resolved: Partial<{ [K in keyof T]: Awaited }> = {}; + entries.forEach(([key], index) => { + resolved[key] = results[index]; + }); + + return resolved as { [K in keyof T]: Awaited }; +} diff --git a/packages/better-auth/src/utils/env.ts b/packages/better-auth/src/utils/env.ts index 89f0a9de..b9678dd0 100644 --- a/packages/better-auth/src/utils/env.ts +++ b/packages/better-auth/src/utils/env.ts @@ -54,4 +54,68 @@ export const isProduction = nodeENV === "production"; export const isDevelopment = nodeENV === "dev" || nodeENV === "development"; /** Detect if `NODE_ENV` environment variable is `test` */ -export const isTest = nodeENV === "test" || toBoolean(env.TEST); +export const isTest = () => nodeENV === "test" || toBoolean(env.TEST); + +/** + * Get environment variable with fallback + */ +export function getEnvVar( + key: string, + fallback?: Fallback, +): Fallback extends string ? string : string | undefined { + if (typeof process !== "undefined" && process.env) { + return process.env[key] ?? (fallback as any); + } + + // @ts-expect-error deno + if (typeof Deno !== "undefined") { + // @ts-expect-error deno + return Deno.env.get(key) ?? (fallback as string); + } + + // Handle Bun + if (typeof Bun !== "undefined") { + return Bun.env[key] ?? (fallback as string); + } + + return fallback as any; +} + +/** + * Get boolean environment variable + */ +export function getBooleanEnvVar(key: string, fallback = true): boolean { + const value = getEnvVar(key); + if (!value) return fallback; + return value !== "0" && value.toLowerCase() !== "false" && value !== ""; +} + +/** + * Common environment variables used in Better Auth + */ +export const ENV = { + get BETTER_AUTH_SECRET() { + return getEnvVar("BETTER_AUTH_SECRET"); + }, + get AUTH_SECRET() { + return getEnvVar("AUTH_SECRET"); + }, + get BETTER_AUTH_TELEMETRY() { + return getEnvVar("BETTER_AUTH_TELEMETRY"); + }, + get BETTER_AUTH_TELEMETRY_ID() { + return getEnvVar("BETTER_AUTH_TELEMETRY_ID"); + }, + get NODE_ENV() { + return getEnvVar("NODE_ENV", "development"); + }, + get PACKAGE_VERSION() { + return getEnvVar("PACKAGE_VERSION", "0.0.0"); + }, + get BETTER_AUTH_TELEMETRY_ENDPOINT() { + return getEnvVar( + "BETTER_AUTH_TELEMETRY_ENDPOINT", + "https://telemetry.better-auth.com/v1/track", + ); + }, +} as const; diff --git a/packages/better-auth/src/utils/get-request-ip.ts b/packages/better-auth/src/utils/get-request-ip.ts index ba01045b..6eaecb16 100644 --- a/packages/better-auth/src/utils/get-request-ip.ts +++ b/packages/better-auth/src/utils/get-request-ip.ts @@ -9,7 +9,7 @@ export function getIp( return null; } - if (isTest) { + if (isTest()) { return "127.0.0.1"; // Use a fixed IP for test environments } diff --git a/packages/better-auth/src/utils/package-json.ts b/packages/better-auth/src/utils/package-json.ts new file mode 100644 index 00000000..9d9c154b --- /dev/null +++ b/packages/better-auth/src/utils/package-json.ts @@ -0,0 +1,60 @@ +import fs from "fs/promises"; +import path from "path"; +import type { PackageJson } from "type-fest"; +let packageJSONCache: PackageJson | undefined; + +async function readRootPackageJson() { + if (packageJSONCache) return packageJSONCache; + try { + const raw = await fs.readFile( + path.join(process.cwd(), "package.json"), + "utf-8", + ); + packageJSONCache = JSON.parse(raw); + return packageJSONCache as PackageJson; + } catch {} + return undefined; +} + +export async function getPackageVersion(pkg: string) { + if (packageJSONCache) { + return (packageJSONCache.dependencies?.[pkg] || + packageJSONCache.devDependencies?.[pkg] || + packageJSONCache.peerDependencies?.[pkg]) as string | undefined; + } + + try { + const pkgJsonPath = path.join( + process.cwd(), + "node_modules", + pkg, + "package.json", + ); + const raw = await fs.readFile(pkgJsonPath, "utf-8"); + const json = JSON.parse(raw); + const resolved = + (json.version as string) || + (await getVersionFromLocalPackageJson(pkg)) || + undefined; + return resolved; + } catch {} + + const fromRoot = await getVersionFromLocalPackageJson(pkg); + return fromRoot; +} + +async function getVersionFromLocalPackageJson(pkg: string) { + const json = await readRootPackageJson(); + if (!json) return undefined; + const allDeps = { + ...json.dependencies, + ...json.devDependencies, + ...json.peerDependencies, + } as Record; + return allDeps[pkg]; +} + +export async function getNameFromLocalPackageJson() { + const json = await readRootPackageJson(); + return json?.name as string | undefined; +} diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 3e9d6df2..92ecc38f 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -3,7 +3,7 @@ import { getConfig } from "../utils/get-config"; import * as z from "zod/v4"; import { existsSync } from "fs"; import path from "path"; -import { logger } from "better-auth"; +import { logger, createTelemetry, getTelemetryAuthConfig } from "better-auth"; import yoctoSpinner from "yocto-spinner"; import prompts from "prompts"; import fs from "fs/promises"; @@ -54,6 +54,21 @@ export async function generateAction(opts: any) { spinner.stop(); if (!schema.code) { logger.info("Your schema is already up to date."); + // telemetry: track generate attempted, no changes + try { + const telemetry = await createTelemetry(config); + await telemetry.publish({ + type: "cli_generate", + payload: { + outcome: "no_changes", + config: getTelemetryAuthConfig(config, { + adapter: adapter.id, + database: + typeof config.database === "function" ? "adapter" : "kysely", + }), + }, + }); + } catch {} process.exit(0); } if (schema.overwrite) { @@ -88,9 +103,31 @@ export async function generateAction(opts: any) { schema.overwrite ? "overwritten" : "appended" } successfully!`, ); + // telemetry: track generate success overwrite/append + try { + const telemetry = await createTelemetry(config); + await telemetry.publish({ + type: "cli_generate", + payload: { + outcome: schema.overwrite ? "overwritten" : "appended", + config: getTelemetryAuthConfig(config), + }, + }); + } catch {} process.exit(0); } else { logger.error("Schema generation aborted."); + // telemetry: track generate aborted + try { + const telemetry = await createTelemetry(config); + await telemetry.publish({ + type: "cli_generate", + payload: { + outcome: "aborted", + config: getTelemetryAuthConfig(config), + }, + }); + } catch {} process.exit(1); } } @@ -115,6 +152,14 @@ export async function generateAction(opts: any) { if (!confirm) { logger.error("Schema generation aborted."); + // telemetry: track generate aborted before write + try { + const telemetry = await createTelemetry(config); + await telemetry.publish({ + type: "cli_generate", + payload: { outcome: "aborted", config: getTelemetryAuthConfig(config) }, + }); + } catch {} process.exit(1); } @@ -131,6 +176,14 @@ export async function generateAction(opts: any) { schema.code, ); logger.success(`🚀 Schema was generated successfully!`); + // telemetry: track generate success + try { + const telemetry = await createTelemetry(config); + await telemetry.publish({ + type: "cli_generate", + payload: { outcome: "generated", config: getTelemetryAuthConfig(config) }, + }); + } catch {} process.exit(0); } diff --git a/packages/cli/src/commands/migrate.ts b/packages/cli/src/commands/migrate.ts index 9cda6447..e1b79f46 100644 --- a/packages/cli/src/commands/migrate.ts +++ b/packages/cli/src/commands/migrate.ts @@ -5,7 +5,7 @@ import path from "path"; import yoctoSpinner from "yocto-spinner"; import chalk from "chalk"; import prompts from "prompts"; -import { logger } from "better-auth"; +import { logger, createTelemetry, getTelemetryAuthConfig } from "better-auth"; import { getAdapter, getMigrations } from "better-auth/db"; import { getConfig } from "../utils/get-config"; @@ -50,15 +50,48 @@ export async function migrateAction(opts: any) { logger.error( "The migrate command only works with the built-in Kysely adapter. For Prisma, run `npx @better-auth/cli generate` to create the schema, then use Prisma’s migrate or push to apply it.", ); + try { + const telemetry = await createTelemetry(config); + await telemetry.publish({ + type: "cli_migrate", + payload: { + outcome: "unsupported_adapter", + adapter: "prisma", + config: getTelemetryAuthConfig(config), + }, + }); + } catch {} process.exit(0); } if (db.id === "drizzle") { logger.error( "The migrate command only works with the built-in Kysely adapter. For Drizzle, run `npx @better-auth/cli generate` to create the schema, then use Drizzle’s migrate or push to apply it.", ); + try { + const telemetry = await createTelemetry(config); + await telemetry.publish({ + type: "cli_migrate", + payload: { + outcome: "unsupported_adapter", + adapter: "drizzle", + config: getTelemetryAuthConfig(config), + }, + }); + } catch {} process.exit(0); } logger.error("Migrate command isn't supported for this adapter."); + try { + const telemetry = await createTelemetry(config); + await telemetry.publish({ + type: "cli_migrate", + payload: { + outcome: "unsupported_adapter", + adapter: db.id, + config: getTelemetryAuthConfig(config), + }, + }); + } catch {} process.exit(1); } @@ -69,6 +102,16 @@ export async function migrateAction(opts: any) { if (!toBeAdded.length && !toBeCreated.length) { spinner.stop(); logger.info("🚀 No migrations needed."); + try { + const telemetry = await createTelemetry(config); + await telemetry.publish({ + type: "cli_migrate", + payload: { + outcome: "no_changes", + config: getTelemetryAuthConfig(config), + }, + }); + } catch {} process.exit(0); } @@ -103,6 +146,13 @@ export async function migrateAction(opts: any) { if (!migrate) { logger.info("Migration cancelled."); + try { + const telemetry = await createTelemetry(config); + await telemetry.publish({ + type: "cli_migrate", + payload: { outcome: "aborted", config: getTelemetryAuthConfig(config) }, + }); + } catch {} process.exit(0); } @@ -110,6 +160,13 @@ export async function migrateAction(opts: any) { await runMigrations(); spinner.stop(); logger.info("🚀 migration was completed successfully!"); + try { + const telemetry = await createTelemetry(config); + await telemetry.publish({ + type: "cli_migrate", + payload: { outcome: "migrated", config: getTelemetryAuthConfig(config) }, + }); + } catch {} process.exit(0); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f2978925..7cecb340 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,18 +1,22 @@ #!/usr/bin/env node import { Command } from "commander"; + +import { init } from "./commands/init"; import { migrate } from "./commands/migrate"; import { generate } from "./commands/generate"; -import "dotenv/config"; import { generateSecret } from "./commands/secret"; import { getPackageInfo } from "./utils/get-package-info"; -import { init } from "./commands/init"; + +import "dotenv/config"; + // handle exit process.on("SIGINT", () => process.exit(0)); process.on("SIGTERM", () => process.exit(0)); async function main() { const program = new Command("better-auth"); + let packageInfo: Record = {}; try { packageInfo = await getPackageInfo(); @@ -20,12 +24,14 @@ async function main() { // it doesn't matter if we can't read the package.json file, we'll just use an empty object } program + .addCommand(init) .addCommand(migrate) .addCommand(generate) .addCommand(generateSecret) - .addCommand(init) .version(packageInfo.version || "1.1.2") - .description("Better Auth CLI"); + .description("Better Auth CLI") + .action(() => program.help()); + program.parse(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5adb916..356a4a80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1567,6 +1567,9 @@ importers: tedious: specifier: ^18.6.1 version: 18.6.1 + type-fest: + specifier: ^4.41.0 + version: 4.41.0 typescript: specifier: 'catalog:' version: 5.9.2
{title}