diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index d7a18d2c72..853653ad7b 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -36,6 +36,7 @@ ".": { "dev-source": "./src/index.ts", "types": "./dist/index.d.mts", + "node": "./dist/node.mjs", "default": "./dist/index.mjs" } }, diff --git a/packages/telemetry/src/detectors/detect-system-info.ts b/packages/telemetry/src/detectors/detect-system-info.ts index 248d10e1e9..7379c8eb2b 100644 --- a/packages/telemetry/src/detectors/detect-system-info.ts +++ b/packages/telemetry/src/detectors/detect-system-info.ts @@ -1,5 +1,4 @@ import { env } from "@better-auth/core/env"; -import { importRuntime } from "../utils/import-util"; function getVendor() { const hasAny = (...keys: string[]) => @@ -73,131 +72,30 @@ function getVendor() { return null; } +// In the default (non-node) build, system info detection is not available. +// The node build (src/node.ts) provides its own inline implementation +// using static top-level imports of node:os and node:fs. export async function detectSystemInfo() { - try { - //check if it's cloudflare - if (getVendor() === "cloudflare") return "cloudflare"; - const os = await importRuntime("os"); - const cpus = os.cpus(); - return { - deploymentVendor: getVendor(), - 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: await isWsl(), - isDocker: await isDocker(), - isTTY: - typeof process !== "undefined" && (process as any).stdout - ? (process as any).stdout.isTTY - : null, - }; - } catch { - return { - systemPlatform: null, - systemRelease: null, - systemArchitecture: null, - cpuCount: null, - cpuModel: null, - cpuSpeed: null, - memory: null, - isWSL: null, - isDocker: null, - isTTY: null, - }; - } -} - -let isDockerCached: boolean | undefined; - -async function hasDockerEnv() { - if (getVendor() === "cloudflare") return false; - - try { - const fs = await importRuntime("fs"); - fs.statSync("/.dockerenv"); - return true; - } catch { - return false; - } -} - -async function hasDockerCGroup() { - if (getVendor() === "cloudflare") return false; - try { - const fs = await importRuntime("fs"); - return fs.readFileSync("/proc/self/cgroup", "utf8").includes("docker"); - } catch { - return false; - } -} - -async function isDocker() { - if (getVendor() === "cloudflare") return false; - - if (isDockerCached === undefined) { - isDockerCached = (await hasDockerEnv()) || (await hasDockerCGroup()); - } - - return isDockerCached; -} - -async function isWsl() { - try { - if (getVendor() === "cloudflare") return false; - if (typeof process === "undefined" || process?.platform !== "linux") { - return false; - } - const fs = await importRuntime("fs"); - const os = await importRuntime("os"); - if (os.release().toLowerCase().includes("microsoft")) { - if (await isInsideContainer()) { - return false; - } - - return true; - } - - return fs - .readFileSync("/proc/version", "utf8") - .toLowerCase() - .includes("microsoft") - ? !(await isInsideContainer()) - : false; - } catch { - return false; - } -} - -let isInsideContainerCached: boolean | undefined; - -const hasContainerEnv = async () => { - if (getVendor() === "cloudflare") return false; - try { - const fs = await importRuntime("fs"); - fs.statSync("/run/.containerenv"); - return true; - } catch { - return false; - } -}; - -async function isInsideContainer() { - if (isInsideContainerCached === undefined) { - isInsideContainerCached = (await hasContainerEnv()) || (await isDocker()); - } - - return isInsideContainerCached; + return { + deploymentVendor: getVendor(), + systemPlatform: null, + systemRelease: null, + systemArchitecture: null, + cpuCount: null, + cpuModel: null, + cpuSpeed: null, + memory: null, + isWSL: null, + isDocker: null, + isTTY: null, + }; } export function isCI() { return ( env.CI !== "false" && ("BUILD_ID" in env || // Jenkins, Cloudbees - "BUILD_NUMBER" in env || // Jenkins, TeamCity (fixed typo: extra space removed) + "BUILD_NUMBER" in env || // Jenkins, TeamCity "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 diff --git a/packages/telemetry/src/node.ts b/packages/telemetry/src/node.ts new file mode 100644 index 0000000000..40e7b36f65 --- /dev/null +++ b/packages/telemetry/src/node.ts @@ -0,0 +1,381 @@ +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { BetterAuthOptions } from "@better-auth/core"; +import { ENV, getBooleanEnvVar, isTest, logger } from "@better-auth/core/env"; +import { betterFetch } from "@better-fetch/fetch"; +import type { PackageJson } from "type-fest"; +import { getTelemetryAuthConfig } from "./detectors/detect-auth-config"; +import { detectPackageManager } from "./detectors/detect-project-info"; +import { detectEnvironment, detectRuntime } from "./detectors/detect-runtime"; +import type { TelemetryContext, TelemetryEvent } from "./types"; +import { hashToBase64 } from "./utils/hash"; +import { generateId } from "./utils/id"; +export { getTelemetryAuthConfig }; +export type { TelemetryEvent } from "./types"; + +// --- Node-specific: package.json reading --- + +let packageJSONCache: PackageJson | undefined; + +async function readRootPackageJson(): Promise { + if (packageJSONCache) return packageJSONCache; + try { + const cwd = process.cwd(); + if (!cwd) return undefined; + const raw = await fsPromises.readFile( + path.join(cwd, "package.json"), + "utf-8", + ); + packageJSONCache = JSON.parse(raw); + return packageJSONCache as PackageJson; + } catch {} + return undefined; +} + +async function getPackageVersion(pkg: string): Promise { + if (packageJSONCache) { + return (packageJSONCache.dependencies?.[pkg] || + packageJSONCache.devDependencies?.[pkg] || + packageJSONCache.peerDependencies?.[pkg]) as string | undefined; + } + + try { + const cwd = process.cwd(); + if (!cwd) throw new Error("no-cwd"); + const pkgJsonPath = path.join(cwd, "node_modules", pkg, "package.json"); + const raw = await fsPromises.readFile(pkgJsonPath, "utf-8"); + const json = JSON.parse(raw); + const resolved = + (json.version as string) || + (await getVersionFromLocalPackageJson(pkg)) || + undefined; + return resolved; + } catch {} + + return getVersionFromLocalPackageJson(pkg); +} + +async function getVersionFromLocalPackageJson( + pkg: string, +): Promise { + const json = await readRootPackageJson(); + if (!json) return undefined; + const allDeps = { + ...json.dependencies, + ...json.devDependencies, + ...json.peerDependencies, + } as Record; + return allDeps[pkg]; +} + +async function getNameFromLocalPackageJson(): Promise { + const json = await readRootPackageJson(); + return json?.name as string | undefined; +} + +// --- Node-specific: system info --- + +async function detectSystemInfo() { + try { + const cpus = os.cpus(); + return { + deploymentVendor: getVendor(), + 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: await isWsl(), + isDocker: await isDocker(), + isTTY: (process as any).stdout ? (process as any).stdout.isTTY : null, + }; + } catch { + return { + systemPlatform: null, + systemRelease: null, + systemArchitecture: null, + cpuCount: null, + cpuModel: null, + cpuSpeed: null, + memory: null, + isWSL: null, + isDocker: null, + isTTY: null, + }; + } +} + +function getVendor() { + const env = process.env as Record; + const hasAny = (...keys: string[]) => keys.some((k) => Boolean(env[k])); + + if ( + hasAny("CF_PAGES", "CF_PAGES_URL", "CF_ACCOUNT_ID") || + (typeof navigator !== "undefined" && + navigator.userAgent === "Cloudflare-Workers") + ) { + return "cloudflare"; + } + + if (hasAny("VERCEL", "VERCEL_URL", "VERCEL_ENV")) return "vercel"; + if (hasAny("NETLIFY", "NETLIFY_URL")) return "netlify"; + if ( + hasAny( + "RENDER", + "RENDER_URL", + "RENDER_INTERNAL_HOSTNAME", + "RENDER_SERVICE_ID", + ) + ) + return "render"; + if ( + hasAny("AWS_LAMBDA_FUNCTION_NAME", "AWS_EXECUTION_ENV", "LAMBDA_TASK_ROOT") + ) + return "aws"; + if ( + hasAny( + "GOOGLE_CLOUD_FUNCTION_NAME", + "GOOGLE_CLOUD_PROJECT", + "GCP_PROJECT", + "K_SERVICE", + ) + ) + return "gcp"; + if ( + hasAny( + "AZURE_FUNCTION_NAME", + "FUNCTIONS_WORKER_RUNTIME", + "WEBSITE_INSTANCE_ID", + "WEBSITE_SITE_NAME", + ) + ) + return "azure"; + if (hasAny("DENO_DEPLOYMENT_ID", "DENO_REGION")) return "deno-deploy"; + if (hasAny("FLY_APP_NAME", "FLY_REGION", "FLY_ALLOC_ID")) return "fly-io"; + if (hasAny("RAILWAY_STATIC_URL", "RAILWAY_ENVIRONMENT_NAME")) + return "railway"; + if (hasAny("DYNO", "HEROKU_APP_NAME")) return "heroku"; + if (hasAny("DO_DEPLOYMENT_ID", "DO_APP_NAME", "DIGITALOCEAN")) + return "digitalocean"; + if (hasAny("KOYEB", "KOYEB_DEPLOYMENT_ID", "KOYEB_APP_NAME")) return "koyeb"; + return null; +} + +let isDockerCached: boolean | undefined; + +async function hasDockerEnv() { + try { + fs.statSync("/.dockerenv"); + return true; + } catch { + return false; + } +} + +async function hasDockerCGroup() { + try { + return fs.readFileSync("/proc/self/cgroup", "utf8").includes("docker"); + } catch { + return false; + } +} + +async function isDocker() { + if (isDockerCached === undefined) { + isDockerCached = (await hasDockerEnv()) || (await hasDockerCGroup()); + } + return isDockerCached; +} + +let isInsideContainerCached: boolean | undefined; + +const hasContainerEnv = async () => { + try { + fs.statSync("/run/.containerenv"); + return true; + } catch { + return false; + } +}; + +async function isInsideContainer() { + if (isInsideContainerCached === undefined) { + isInsideContainerCached = (await hasContainerEnv()) || (await isDocker()); + } + return isInsideContainerCached; +} + +async function isWsl() { + try { + if (process.platform !== "linux") { + return false; + } + if (os.release().toLowerCase().includes("microsoft")) { + if (await isInsideContainer()) { + return false; + } + return true; + } + + return fs + .readFileSync("/proc/version", "utf8") + .toLowerCase() + .includes("microsoft") + ? !(await isInsideContainer()) + : false; + } catch { + return false; + } +} + +// --- Node-specific: project ID --- + +let projectIdCached: string | null = null; + +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; +} + +// --- detectDatabase/detectFramework override using local package.json reading --- + +async function detectDatabaseNode() { + const DATABASES: Record = { + pg: "postgresql", + mysql: "mysql", + mariadb: "mariadb", + sqlite3: "sqlite", + "better-sqlite3": "sqlite", + "@prisma/client": "prisma", + mongoose: "mongodb", + mongodb: "mongodb", + "drizzle-orm": "drizzle", + }; + for (const [pkg, name] of Object.entries(DATABASES)) { + const version = await getPackageVersion(pkg); + if (version) return { name, version }; + } + return undefined; +} + +async function detectFrameworkNode() { + const FRAMEWORKS: Record = { + next: "next", + nuxt: "nuxt", + "react-router": "react-router", + astro: "astro", + "@sveltejs/kit": "sveltekit", + "solid-start": "solid-start", + "tanstack-start": "tanstack-start", + hono: "hono", + express: "express", + elysia: "elysia", + expo: "expo", + }; + for (const [pkg, name] of Object.entries(FRAMEWORKS)) { + const version = await getPackageVersion(pkg); + if (version) return { name, version }; + } + return undefined; +} + +// --- Main telemetry export (node version) --- + +const noop: (event: TelemetryEvent) => Promise = async function noop() {}; + +export async function createTelemetry( + options: BetterAuthOptions, + context?: TelemetryContext | undefined, +) { + const debugEnabled = + options.telemetry?.debug || + getBooleanEnvVar("BETTER_AUTH_TELEMETRY_DEBUG", false); + + const telemetryEndpoint = ENV.BETTER_AUTH_TELEMETRY_ENDPOINT; + if (!telemetryEndpoint && !context?.customTrack) { + return { + publish: noop, + }; + } + const track = async (event: TelemetryEvent) => { + if (context?.customTrack) { + await context.customTrack(event).catch(logger.error); + } else if (telemetryEndpoint) { + if (debugEnabled) { + logger.info("telemetry event", JSON.stringify(event, null, 2)); + } else { + await betterFetch(telemetryEndpoint, { + method: "POST", + body: event, + }).catch(logger.error); + } + } + }; + + const isEnabled = async () => { + const telemetryEnabled = + options.telemetry?.enabled !== undefined + ? options.telemetry.enabled + : false; + const envEnabled = getBooleanEnvVar("BETTER_AUTH_TELEMETRY", false); + return ( + (envEnabled || telemetryEnabled) && (context?.skipTestCheck || !isTest()) + ); + }; + + const enabled = await isEnabled(); + let anonymousId: string | undefined; + + if (enabled) { + anonymousId = await getProjectId( + typeof options.baseURL === "string" ? options.baseURL : undefined, + ); + + const payload = { + config: await getTelemetryAuthConfig(options, context), + runtime: detectRuntime(), + database: await detectDatabaseNode(), + framework: await detectFrameworkNode(), + environment: detectEnvironment(), + systemInfo: await detectSystemInfo(), + packageManager: detectPackageManager(), + }; + + void track({ type: "init", payload, anonymousId }); + } + + return { + publish: async (event: TelemetryEvent) => { + if (!enabled) return; + if (!anonymousId) { + anonymousId = await getProjectId( + typeof options.baseURL === "string" ? options.baseURL : undefined, + ); + } + await track({ + type: event.type, + payload: event.payload, + anonymousId, + }); + }, + }; +} diff --git a/packages/telemetry/src/utils/import-util.ts b/packages/telemetry/src/utils/import-util.ts deleted file mode 100644 index 2fe75f5507..0000000000 --- a/packages/telemetry/src/utils/import-util.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const importRuntime = (m: string): Promise => { - return (Function("mm", "return import(mm)") as any)(m); -}; diff --git a/packages/telemetry/src/utils/package-json.ts b/packages/telemetry/src/utils/package-json.ts index 51791be844..594b945181 100644 --- a/packages/telemetry/src/utils/package-json.ts +++ b/packages/telemetry/src/utils/package-json.ts @@ -1,75 +1,15 @@ -import type { PackageJson } from "type-fest"; +// In the default (non-node) build, filesystem access is not available. +// The node build (src/node.ts) provides its own inline implementation +// using static top-level imports of node:fs/promises and node:path. -let packageJSONCache: PackageJson | undefined; - -async function readRootPackageJson() { - if (packageJSONCache) return packageJSONCache; - try { - const cwd = - typeof process !== "undefined" && typeof process.cwd === "function" - ? process.cwd() - : ""; - if (!cwd) return undefined; - // Lazily import Node built-ins only when available (Node/Bun/Deno) and - // avoid static analyzer/bundler resolution by obfuscating module names - const importRuntime = (m: string) => - (Function("mm", "return import(mm)") as any)(m); - const [{ default: fs }, { default: path }] = await Promise.all([ - importRuntime("fs/promises"), - importRuntime("path"), - ]); - const raw = await fs.readFile(path.join(cwd, "package.json"), "utf-8"); - packageJSONCache = JSON.parse(raw); - return packageJSONCache as PackageJson; - } catch {} +export async function getPackageVersion( + _pkg: string, +): Promise { 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 cwd = - typeof process !== "undefined" && typeof process.cwd === "function" - ? process.cwd() - : ""; - if (!cwd) throw new Error("no-cwd"); - const importRuntime = (m: string) => - (Function("mm", "return import(mm)") as any)(m); - const [{ default: fs }, { default: path }] = await Promise.all([ - importRuntime("fs/promises"), - importRuntime("path"), - ]); - const pkgJsonPath = path.join(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; +export async function getNameFromLocalPackageJson(): Promise< + string | undefined +> { + return undefined; } diff --git a/packages/telemetry/tsdown.config.ts b/packages/telemetry/tsdown.config.ts index d100d00ed5..ef780f9c8a 100644 --- a/packages/telemetry/tsdown.config.ts +++ b/packages/telemetry/tsdown.config.ts @@ -1,8 +1,16 @@ import { defineConfig } from "tsdown"; -export default defineConfig({ - dts: { build: true, incremental: true }, - format: ["esm"], - entry: ["./src/index.ts"], - sourcemap: true, -}); +export default defineConfig([ + { + dts: { build: true, incremental: true }, + format: ["esm"], + entry: ["./src/index.ts"], + sourcemap: true, + }, + { + dts: { build: true, incremental: true }, + format: ["esm"], + entry: ["./src/node.ts"], + sourcemap: true, + }, +]);