mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-09 12:27:43 +00:00
feat: add telemetry (#3822)
* feat: telemetry Co-authored-by: Kinfe123 <kinfishtech@gmail.com> * chore: remove changeset * fix: do not generate project id unless telemetry is enabled * fix: return `isInsiderContainerCached` Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * chore: remove unused utils file * fix: properly cache generated project id * feat: interpret empty env vars as false Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix: use nullish coalescing to set fallback * fix: should be `isInsideContainerCached` * fix: unique icons + tooltip for telemetry component * fix: import child process from node * fix: remove quotes in description Co-authored-by: Alex Yang <himself65@outlook.com> * fix: address reviews Co-authored-by: Alex Yang <himself65@outlook.com> * chore: refactor * refactor * add tests * cache pkg json * add cli tracking * add migrate * chore fix xi * skip tet * update snapshot * chore: fix typecheck * Expand telemetry docs: list collected fields, clarify anonymous redaction via getTelemetryAuthConfig, and document CLI events and audit/opt‑out paths. * docs * doc cleanup * fixes * remove git first commit message * update docs --------- Co-authored-by: Kinfe123 <kinfishtech@gmail.com> Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Alex Yang <himself65@outlook.com> Co-authored-by: Bereket Engida <Bekacru@gmail.com>
This commit is contained in:
@@ -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) => (
|
||||
<iframe {...props} className="w-full h-[500px]" />
|
||||
),
|
||||
Telemetry,
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
138
docs/components/mdx/telemetry.tsx
Normal file
138
docs/components/mdx/telemetry.tsx
Normal file
@@ -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 (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{telemetryPoints.map(({ icon: Icon, label, title }, index) => (
|
||||
<Tooltip key={index}>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1.5 cursor-help"
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-sm">{title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -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: () => <Book className="w-4 h-4 text-current" />,
|
||||
},
|
||||
|
||||
{
|
||||
title: "Security",
|
||||
href: "/docs/reference/security",
|
||||
icon: () => <ShieldCheck className="w-4 h-4 text-current" />,
|
||||
},
|
||||
{
|
||||
title: "Telemetry",
|
||||
href: "/docs/reference/telemetry",
|
||||
icon: () => <Binoculars className="w-4 h-4 text-current" />,
|
||||
},
|
||||
{
|
||||
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: () => <BookOpenCheck className="text-current size-4" />,
|
||||
// },
|
||||
// {
|
||||
// title: "Areas to Contribute",
|
||||
// href: "/docs/contribute/areas-to-contribute",
|
||||
// icon: () => <HandHelping className="w-4 h-4 text-current" />,
|
||||
// },
|
||||
// // {
|
||||
// // title: "Database Adapters",
|
||||
// // href: "/docs/contribute/database-adapters",
|
||||
// // icon: () => <Plug className="w-4 h-4 text-current" />,
|
||||
// // },
|
||||
// {
|
||||
// title: "Testing",
|
||||
// href: "/docs/contribute/testing",
|
||||
// icon: () => <FlaskConical className="w-4 h-4 text-current" />,
|
||||
// },
|
||||
// {
|
||||
// title: "Documenting",
|
||||
// href: "/docs/contribute/documenting",
|
||||
// icon: () => <NotebookPen className="w-4 h-4 text-current" />,
|
||||
// },
|
||||
// {
|
||||
// title: "Security Issues",
|
||||
// href: "/docs/contribute/security-issues",
|
||||
// icon: () => <ShieldCheck className="w-4 h-4 text-current" />,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
];
|
||||
|
||||
export const examples: Content[] = [
|
||||
|
||||
@@ -52,7 +52,7 @@ function TooltipContent({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="-z-10 relative bg-primary dark:bg-stone-900 dark:fill-stone-900 fill-primary size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
<TooltipPrimitive.Arrow className="-z-10 relative bg-primary fill-primary size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
68
docs/content/docs/reference/telemetry.mdx
Normal file
68
docs/content/docs/reference/telemetry.mdx
Normal file
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -127,6 +127,7 @@ exports[`init > should match config 1`] = `
|
||||
"hash": [Function],
|
||||
"verify": [Function],
|
||||
},
|
||||
"publishTelemetry": [Function],
|
||||
"rateLimit": {
|
||||
"enabled": false,
|
||||
"max": 100,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<typeof getAuthTables>;
|
||||
runMigrations: () => Promise<void>;
|
||||
publishTelemetry: (event: TelemetryEvent) => Promise<void>;
|
||||
};
|
||||
|
||||
function runPluginInit(ctx: AuthContext) {
|
||||
|
||||
0
packages/better-auth/src/telemetry/README.md
Normal file
0
packages/better-auth/src/telemetry/README.md
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getPackageVersion } from "../../utils/package-json";
|
||||
import type { DetectionInfo } from "../types";
|
||||
|
||||
const DATABASES: Record<string, string> = {
|
||||
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<DetectionInfo | undefined> {
|
||||
for (const [pkg, name] of Object.entries(DATABASES)) {
|
||||
const version = await getPackageVersion(pkg);
|
||||
if (version) return { name, version };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getPackageVersion } from "../../utils/package-json";
|
||||
|
||||
const FRAMEWORKS: Record<string, string> = {
|
||||
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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
76
packages/better-auth/src/telemetry/index.ts
Normal file
76
packages/better-auth/src/telemetry/index.ts
Normal file
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
27
packages/better-auth/src/telemetry/project-id.ts
Normal file
27
packages/better-auth/src/telemetry/project-id.ts
Normal file
@@ -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<string> {
|
||||
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;
|
||||
}
|
||||
298
packages/better-auth/src/telemetry/telemetry.test.ts
Normal file
298
packages/better-auth/src/telemetry/telemetry.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
46
packages/better-auth/src/telemetry/types.ts
Normal file
46
packages/better-auth/src/telemetry/types.ts
Normal file
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
export interface TelemetryContext {
|
||||
customTrack?: (event: TelemetryEvent) => Promise<void>;
|
||||
database?: string;
|
||||
adapter?: string;
|
||||
skipTestCheck?: boolean;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
13
packages/better-auth/src/utils/await-object.ts
Normal file
13
packages/better-auth/src/utils/await-object.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export async function awaitObject<T extends Record<string, Promise<any>>>(
|
||||
promises: T,
|
||||
): Promise<{ [K in keyof T]: Awaited<T[K]> }> {
|
||||
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<T[K]> }> = {};
|
||||
entries.forEach(([key], index) => {
|
||||
resolved[key] = results[index];
|
||||
});
|
||||
|
||||
return resolved as { [K in keyof T]: Awaited<T[K]> };
|
||||
}
|
||||
@@ -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<Fallback extends string>(
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
60
packages/better-auth/src/utils/package-json.ts
Normal file
60
packages/better-auth/src/utils/package-json.ts
Normal file
@@ -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<string, string | undefined>;
|
||||
return allDeps[pkg];
|
||||
}
|
||||
|
||||
export async function getNameFromLocalPackageJson() {
|
||||
const json = await readRootPackageJson();
|
||||
return json?.name as string | undefined;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, any> = {};
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user