diff --git a/packages/otel-better-auth/README.md b/packages/otel-better-auth/README.md index a2d2d79..5d58736 100644 --- a/packages/otel-better-auth/README.md +++ b/packages/otel-better-auth/README.md @@ -1,15 +1,6 @@ # @kubiks/otel-better-auth -OpenTelemetry instrumentation for [Better Auth](https://better-auth.com/). Add distributed tracing to your authentication flows with a single line of code. - -## 🚀 Features - -- **🔌 Plugin-based**: Clean integration using Better Auth's native plugin system -- **📊 Comprehensive Coverage**: Traces all auth operations (signup, signin, OAuth, password reset, etc.) -- **🎯 Semantic Conventions**: Follows OpenTelemetry standards with meaningful attributes -- **🔐 Privacy-First**: Email capture is opt-in by default -- **⚡ Zero Config**: Works out of the box with sensible defaults -- **🎨 Rich Telemetry**: Captures user IDs, session IDs, auth methods, and success/failure status +OpenTelemetry instrumentation for [Better Auth](https://better-auth.com/). One-line setup for complete auth observability. ## Installation @@ -17,17 +8,13 @@ OpenTelemetry instrumentation for [Better Auth](https://better-auth.com/). Add d npm install @kubiks/otel-better-auth # or pnpm add @kubiks/otel-better-auth -# or -yarn add @kubiks/otel-better-auth ``` **Peer Dependencies:** `@opentelemetry/api` >= 1.9.0, `better-auth` >= 0.1.0 ## Usage -### Basic Setup (One Line!) - -Simply add the plugin to your Better Auth configuration: +Add the plugin to your Better Auth configuration: ```typescript import { betterAuth } from "better-auth"; @@ -35,267 +22,114 @@ import { otelPlugin } from "@kubiks/otel-better-auth"; export const auth = betterAuth({ database: db, - emailAndPassword: { - enabled: true, - }, + // ... your config }).use(otelPlugin()); - -// That's it! All auth operations are now traced automatically ✨ ``` -### With Custom Configuration +That's it! All auth operations are now traced automatically. -```typescript -import { betterAuth } from "better-auth"; -import { otelPlugin } from "@kubiks/otel-better-auth"; +## What Gets Traced -export const auth = betterAuth({ - database: db, - emailAndPassword: { - enabled: true, - }, - socialProviders: { - google: { - clientId: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - }, - github: { - clientId: process.env.GITHUB_CLIENT_ID, - clientSecret: process.env.GITHUB_CLIENT_SECRET, - }, - }, -}).use( - otelPlugin({ - tracerName: "my-app-auth", // Custom tracer name - captureEmail: true, // Include email addresses in traces (default: false) - captureErrors: true, // Capture detailed error messages (default: true) - }) -); -``` +### Email/Password Auth +- `auth.signup.email` - User signup +- `auth.signin.email` - User signin -### Full Example with OpenTelemetry Setup +### OAuth +- `auth.oauth.{provider}.initiate` - User clicks "Sign in with..." +- `auth.oauth.{provider}` - OAuth callback processing -```typescript -// instrumentation.ts -import { NodeSDK } from "@opentelemetry/sdk-node"; -import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node"; -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +### Password Management +- `auth.forgot_password` - Forgot password request +- `auth.reset_password` - Password reset +- `auth.verify_email` - Email verification -const sdk = new NodeSDK({ - traceExporter: new OTLPTraceExporter({ - url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, - }), - instrumentations: [getNodeAutoInstrumentations()], -}); - -sdk.start(); - -// auth.ts -import { betterAuth } from "better-auth"; -import { otelPlugin } from "@kubiks/otel-better-auth"; -import { drizzle } from "drizzle-orm/node-postgres"; -import { Pool } from "pg"; - -const pool = new Pool({ connectionString: process.env.DATABASE_URL }); -const db = drizzle(pool); - -export const auth = betterAuth({ - database: db, - emailAndPassword: { - enabled: true, - }, - socialProviders: { - google: { - clientId: process.env.GOOGLE_CLIENT_ID!, - clientSecret: process.env.GOOGLE_CLIENT_SECRET!, - }, - }, -}).use(otelPlugin()); - -// app.ts -import "./instrumentation"; // Must be imported first! -import { auth } from "./auth"; - -// Your auth is now fully instrumented! -``` - -## What You Get - -The plugin automatically traces the following authentication events: - -### 📝 Signup Operations - -- Email/password signup -- OAuth provider signup -- Magic link signup -- Automatic user ID capture - -**Span name**: `auth.signup` or `auth.signup.email` - -### 🔑 Signin Operations - -- Email/password signin -- OAuth provider signin (Google, GitHub, Facebook, etc.) -- Magic link signin -- Session creation tracking - -**Span name**: `auth.signin`, `auth.signin.email`, or `auth.oauth.{provider}` - -### 🔒 Password Management - -- Forgot password requests -- Password reset flows -- Email verification - -**Span names**: `auth.forgot_password`, `auth.reset_password`, `auth.verify_email` - -### 🚪 Signout - -- Session termination tracking - -**Span name**: `auth.signout` - -### 🌐 OAuth Flows - -- Automatic provider detection (Google, GitHub, Facebook, etc.) -- Callback tracking -- Success/failure monitoring - -**Span name**: `auth.oauth.{provider}` +### Session +- `auth.signout` - User signout ## Span Attributes -Each traced operation includes rich telemetry data following OpenTelemetry semantic conventions: +Each span includes: -| Attribute | Description | Example | -| ------------------ | ------------------------------ | ------------------------ | -| `auth.operation` | Type of auth operation | `signin`, `signup` | -| `auth.method` | Authentication method | `password`, `oauth` | -| `auth.provider` | OAuth provider (if applicable) | `google`, `github` | -| `auth.success` | Whether operation succeeded | `true`, `false` | -| `auth.error` | Error message (if failed) | `Invalid credentials` | -| `user.id` | User identifier | `user_123456` | -| `user.email` | User email (opt-in) | `user@example.com` | -| `session.id` | Session identifier | `session_789012` | +| Attribute | Description | Example | +|-----------|-------------|---------| +| `auth.operation` | Type of operation | `signin`, `signup`, `oauth_callback` | +| `auth.method` | Auth method | `password`, `oauth` | +| `auth.provider` | OAuth provider | `google`, `github` | +| `auth.success` | Operation success | `true`, `false` | +| `auth.error` | Error message | `HTTP 401` | -## Configuration Options +## Configuration -### `tracerName` - -- **Type**: `string` -- **Default**: `"@kubiks/otel-better-auth"` -- **Description**: Custom name for the tracer - -### `captureEmail` - -- **Type**: `boolean` -- **Default**: `false` -- **Description**: Whether to include user email addresses in span attributes. **Note**: Email addresses are PII (Personally Identifiable Information). Only enable this if your tracing backend is compliant with your privacy requirements. - -### `captureErrors` - -- **Type**: `boolean` -- **Default**: `true` -- **Description**: Whether to capture detailed error messages in spans - -### `tracer` - -- **Type**: `Tracer` -- **Default**: `undefined` -- **Description**: Custom OpenTelemetry tracer instance. If not provided, the plugin will obtain a tracer using `trace.getTracer(tracerName)`. - -## Privacy & Security - -By default, the plugin is designed with privacy in mind: - -- ✅ User emails are **NOT** captured by default -- ✅ Passwords are **NEVER** captured -- ✅ Only operation metadata and success/failure status are traced -- ⚠️ Enable `captureEmail: true` only if your infrastructure is compliant with privacy regulations (GDPR, CCPA, etc.) - -## Architecture - -The plugin leverages Better Auth's powerful plugin API to hook into: - -1. **Lifecycle Hooks**: `user.create`, `session.create` for core auth events -2. **Endpoint Hooks**: All auth endpoints (`signInEmail`, `signUpEmail`, `forgetPassword`, etc.) -3. **Request/Response Hooks**: For OAuth callback detection and tracing - -This provides comprehensive coverage of all authentication flows without any code changes to your application. - -## Visualizing Traces - -When integrated with a tracing backend (Jaeger, Zipkin, Honeycomb, Datadog, etc.), you'll see: - -- 📊 End-to-end auth flow visualization -- ⏱️ Performance metrics for each auth operation -- 🔍 Detailed attributes for debugging -- 🚨 Error tracking with stack traces -- 📈 Success/failure rates across auth methods +```typescript +otelPlugin({ + tracerName: "my-app-auth", // Custom tracer name + tracer: customTracer, // Custom tracer instance +}) +``` ## Examples -### Next.js App Router +### Next.js ```typescript -// app/api/auth/[...all]/route.ts -import { auth } from "@/lib/auth"; -import { toNextJsHandler } from "better-auth/next-js"; +// lib/auth.ts +import { betterAuth } from "better-auth"; +import { otelPlugin } from "@kubiks/otel-better-auth"; -export const { GET, POST } = toNextJsHandler(auth.handler); +export const auth = betterAuth({ + database: db, + socialProviders: { + github: { clientId: "...", clientSecret: "..." }, + google: { clientId: "...", clientSecret: "..." }, + }, +}).use(otelPlugin()); ``` -### Express +### With Other Plugins ```typescript -import express from "express"; -import { auth } from "./auth"; +import { organization, admin } from "better-auth/plugins"; +import { otelPlugin } from "@kubiks/otel-better-auth"; -const app = express(); - -app.all("/api/auth/*", auth.handler); - -app.listen(3000); +export const auth = betterAuth({ + database: db, +}).use( + organization(), + admin(), + otelPlugin() // Works with any Better Auth plugin +); ``` -### SvelteKit +## Trace Example -```typescript -// src/hooks.server.ts -import { auth } from "$lib/auth"; -import { svelteKitHandler } from "better-auth/svelte-kit"; +When a user signs in with GitHub: -export const handle = svelteKitHandler(auth); +``` +Span: auth.oauth.github.initiate [12ms] +├─ auth.operation: oauth_initiate +├─ auth.method: oauth +├─ auth.provider: github +└─ auth.success: true + +Span: auth.oauth.github [245ms] +├─ auth.operation: oauth_callback +├─ auth.method: oauth +├─ auth.provider: github +└─ auth.success: true ``` -## Compatibility +## Framework Support -- ✅ Works with all Better Auth adapters (Drizzle, Prisma, Kysely, etc.) -- ✅ Compatible with all Better Auth plugins -- ✅ Framework agnostic (Next.js, Express, SvelteKit, etc.) -- ✅ Supports all authentication methods (email/password, OAuth, magic link) +✅ Next.js (App Router & Pages Router) +✅ Express +✅ SvelteKit +✅ Any framework supported by Better Auth -## Best Practices +## Related -1. **Initialize OpenTelemetry early**: Import your instrumentation file before any other code -2. **Use environment-based configuration**: Enable `captureEmail` only in development/staging -3. **Combine with other instrumentation**: Use alongside `@kubiks/otel-drizzle` for database query tracing -4. **Monitor performance**: Set up alerts for slow auth operations or high failure rates -5. **Respect privacy**: Be mindful of what PII you capture in production traces - -## Related Packages - -- [`@kubiks/otel-drizzle`](https://www.npmjs.com/package/@kubiks/otel-drizzle) - OpenTelemetry instrumentation for Drizzle ORM -- [`better-auth`](https://better-auth.com/) - The best authentication library for TypeScript - -## Contributing - -We welcome contributions! Please check out our [GitHub repository](https://github.com/kubiks-inc/otel) for issues and pull requests. +- [`@kubiks/otel-drizzle`](../otel-drizzle) - OpenTelemetry for Drizzle ORM +- [`better-auth`](https://better-auth.com/) - The authentication library for TypeScript ## License MIT - ---- - -Made with ❤️ by [Kubiks](https://github.com/kubiks-inc) diff --git a/packages/otel-better-auth/src/index.test.ts b/packages/otel-better-auth/src/index.test.ts index 6adec7e..5350502 100644 --- a/packages/otel-better-auth/src/index.test.ts +++ b/packages/otel-better-auth/src/index.test.ts @@ -1,8 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { trace, SpanStatusCode } from "@opentelemetry/api"; -import { InMemorySpanExporter } from "@opentelemetry/sdk-trace-base"; -import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; -import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"; +import { describe, it, expect } from "vitest"; import { otelPlugin, SEMATTRS_AUTH_OPERATION, @@ -10,342 +6,73 @@ import { SEMATTRS_AUTH_PROVIDER, SEMATTRS_USER_ID, SEMATTRS_USER_EMAIL, + SEMATTRS_SESSION_ID, SEMATTRS_AUTH_SUCCESS, + SEMATTRS_AUTH_ERROR, } from "./index.js"; describe("otel-better-auth", () => { - let exporter: InMemorySpanExporter; - let provider: NodeTracerProvider; - - beforeEach(() => { - exporter = new InMemorySpanExporter(); - provider = new NodeTracerProvider(); - provider.addSpanProcessor(new SimpleSpanProcessor(exporter)); - provider.register(); - }); - - afterEach(() => { - exporter.reset(); - provider.shutdown(); - }); - describe("otelPlugin", () => { it("should create a plugin with correct id", () => { const plugin = otelPlugin(); expect(plugin.id).toBe("otel"); }); - it("should have before and after hooks", () => { + it("should have onRequest handler", () => { const plugin = otelPlugin(); - expect(plugin.hooks?.before).toBeDefined(); - expect(plugin.hooks?.after).toBeDefined(); - expect(Array.isArray(plugin.hooks?.before)).toBe(true); - expect(Array.isArray(plugin.hooks?.after)).toBe(true); + expect(plugin.onRequest).toBeDefined(); + expect(typeof plugin.onRequest).toBe("function"); }); - it("should have multiple before hooks for different endpoints", () => { + it("should have onResponse handler", () => { const plugin = otelPlugin(); - expect(plugin.hooks?.before?.length).toBeGreaterThan(3); + expect(plugin.onResponse).toBeDefined(); + expect(typeof plugin.onResponse).toBe("function"); }); - it("should have after hook for span finalization", () => { - const plugin = otelPlugin(); - expect(plugin.hooks?.after?.length).toBe(1); - }); - }); - - describe("Hook matchers", () => { - it("should match signup endpoints", () => { - const plugin = otelPlugin(); - const signupHook = plugin.hooks?.before?.find(h => { - return h.matcher({ path: "/sign-up/email", request: {} } as any); - }); - expect(signupHook).toBeDefined(); - }); - - it("should match signin endpoints", () => { - const plugin = otelPlugin(); - const signinHook = plugin.hooks?.before?.find(h => { - return h.matcher({ path: "/sign-in/email", request: {} } as any); - }); - expect(signinHook).toBeDefined(); - }); - - it("should match forgot password endpoints", () => { - const plugin = otelPlugin(); - const forgotHook = plugin.hooks?.before?.find(h => { - return h.matcher({ path: "/forget-password", request: {} } as any); - }); - expect(forgotHook).toBeDefined(); - }); - - it("should match OAuth callback endpoints", () => { - const plugin = otelPlugin(); - const oauthHook = plugin.hooks?.before?.find(h => { - return h.matcher({ path: "/callback/google", request: {} } as any); - }); - expect(oauthHook).toBeDefined(); - }); - }); - - describe("Configuration", () => { - it("should use custom tracer name", () => { - const customName = "my-custom-auth-tracer"; - const plugin = otelPlugin({ tracerName: customName }); + it("should accept custom tracer name", () => { + const plugin = otelPlugin({ tracerName: "custom-tracer" }); expect(plugin.id).toBe("otel"); }); it("should accept custom tracer instance", () => { - const customTracer = trace.getTracer("custom-tracer"); - const plugin = otelPlugin({ tracer: customTracer }); + const plugin = otelPlugin({ tracer: {} as any }); expect(plugin.id).toBe("otel"); }); - - it("should respect captureEmail setting", () => { - const plugin = otelPlugin({ captureEmail: true }); - expect(plugin.id).toBe("otel"); - }); - - it("should respect captureErrors setting", () => { - const plugin = otelPlugin({ captureErrors: false }); - expect(plugin.id).toBe("otel"); - }); - }); - - describe("Span creation", () => { - it("should create span for signup", async () => { - const plugin = otelPlugin(); - const signupHook = plugin.hooks?.before?.find(h => { - return h.matcher({ path: "/sign-up/email", request: {} } as any); - }); - - const ctx = { - path: "/sign-up/email", - body: { email: "test@example.com" }, - request: {}, - }; - - await signupHook?.handler(ctx as any); - - // Span is created but not finalized yet - expect((ctx as any).__otelSpan).toBeDefined(); - }); - - it("should capture email when enabled", async () => { - const plugin = otelPlugin({ captureEmail: true }); - const signupHook = plugin.hooks?.before?.find(h => { - return h.matcher({ path: "/sign-up/email", request: {} } as any); - }); - - const ctx = { - path: "/sign-up/email", - body: { email: "test@example.com" }, - request: {}, - }; - - await signupHook?.handler(ctx as any); - - const span = (ctx as any).__otelSpan; - expect(span).toBeDefined(); - }); - - it("should create span for OAuth callback", async () => { - const plugin = otelPlugin(); - const oauthHook = plugin.hooks?.before?.find(h => { - return h.matcher({ path: "/callback/google", request: {} } as any); - }); - - const ctx = { - path: "/callback/google?code=abc123", - request: {}, - }; - - await oauthHook?.handler(ctx as any); - - expect((ctx as any).__otelSpan).toBeDefined(); - }); - }); - - describe("Span creation and finalization", () => { - it("should attach span to context in before hook", async () => { - const plugin = otelPlugin(); - const signupHook = plugin.hooks?.before?.find(h => { - return h.matcher({ path: "/sign-up/email", request: {} } as any); - }); - - const ctx = { - path: "/sign-up/email", - body: { email: "test@example.com" }, - request: {}, - returned: { status: 200 }, - }; - - await signupHook?.handler(ctx as any); - - // Verify span was attached - expect((ctx as any).__otelSpan).toBeDefined(); - expect((ctx as any).__otelContext).toBeDefined(); - }); - - it("should cleanup span in after hook", async () => { - const plugin = otelPlugin(); - const signupHook = plugin.hooks?.before?.find(h => { - return h.matcher({ path: "/sign-up/email", request: {} } as any); - }); - - const ctx = { - path: "/sign-up/email", - body: { email: "test@example.com" }, - request: {}, - returned: { status: 200 }, - }; - - await signupHook?.handler(ctx as any); - - const afterHook = plugin.hooks?.after?.[0]; - await afterHook?.handler(ctx as any); - - // Verify span was cleaned up - expect((ctx as any).__otelSpan).toBeUndefined(); - expect((ctx as any).__otelContext).toBeUndefined(); - }); - - it("should handle error contexts", async () => { - const plugin = otelPlugin(); - const signinHook = plugin.hooks?.before?.find(h => { - return h.matcher({ path: "/sign-in/email", request: {} } as any); - }); - - const ctx = { - path: "/sign-in/email", - body: { email: "test@example.com" }, - request: {}, - error: new Error("Invalid credentials"), - returned: { status: 401 }, - }; - - await signinHook?.handler(ctx as any); - - const afterHook = plugin.hooks?.after?.[0]; - // Should not throw - expect(async () => await afterHook?.handler(ctx as any)).not.toThrow(); - }); }); describe("Semantic conventions", () => { - it("should export semantic attribute constants", () => { + it("should export correct attribute constants", () => { expect(SEMATTRS_AUTH_OPERATION).toBe("auth.operation"); expect(SEMATTRS_AUTH_METHOD).toBe("auth.method"); expect(SEMATTRS_AUTH_PROVIDER).toBe("auth.provider"); expect(SEMATTRS_USER_ID).toBe("user.id"); expect(SEMATTRS_USER_EMAIL).toBe("user.email"); + expect(SEMATTRS_SESSION_ID).toBe("session.id"); expect(SEMATTRS_AUTH_SUCCESS).toBe("auth.success"); - }); - - it("should create spans with correct operation types", async () => { - const plugin = otelPlugin({ captureEmail: true }); - const signupHook = plugin.hooks?.before?.find(h => { - return h.matcher({ path: "/sign-up/email", request: {} } as any); - }); - - const ctx = { - path: "/sign-up/email", - body: { email: "test@example.com" }, - request: {}, - }; - - await signupHook?.handler(ctx as any); - - // Span should be created and attached - expect((ctx as any).__otelSpan).toBeDefined(); - }); - - it("should support different OAuth providers", async () => { - const plugin = otelPlugin(); - const providers = ["google", "github", "facebook"]; - - for (const provider of providers) { - const hook = plugin.hooks?.before?.find(h => { - return h.matcher({ path: `/callback/${provider}`, request: {} } as any); - }); - - const ctx = { - path: `/callback/${provider}?code=abc`, - request: {}, - }; - - await hook?.handler(ctx as any); - - expect((ctx as any).__otelSpan).toBeDefined(); - - const afterHook = plugin.hooks?.after?.[0]; - await afterHook?.handler(ctx as any); - } + expect(SEMATTRS_AUTH_ERROR).toBe("auth.error"); }); }); - describe("Multiple operations", () => { - it("should handle multiple auth operations sequentially", async () => { + describe("Plugin structure", () => { + it("should be compatible with Better Auth plugin interface", () => { const plugin = otelPlugin(); - - // Simulate multiple auth operations - const operations = [ - { path: "/sign-up/email", matcher: "signup" }, - { path: "/sign-in/email", matcher: "signin" }, - { path: "/forget-password", matcher: "forgot" }, - ]; - - for (const op of operations) { - const hook = plugin.hooks?.before?.find(h => { - return h.matcher({ path: op.path, request: {} } as any); - }); - - expect(hook).toBeDefined(); - - const ctx = { - path: op.path, - body: { email: "test@example.com" }, - request: {}, - returned: { status: 200 }, - }; - - await hook?.handler(ctx as any); - expect((ctx as any).__otelSpan).toBeDefined(); - - await plugin.hooks?.after?.[0].handler(ctx as any); - expect((ctx as any).__otelSpan).toBeUndefined(); - } + + expect(plugin).toHaveProperty("id"); + expect(plugin).toHaveProperty("onRequest"); + expect(plugin).toHaveProperty("onResponse"); + + expect(typeof plugin.id).toBe("string"); + expect(typeof plugin.onRequest).toBe("function"); + expect(typeof plugin.onResponse).toBe("function"); }); - it("should handle concurrent operations", async () => { - const plugin = otelPlugin(); + it("should not throw when created with no config", () => { + expect(() => otelPlugin()).not.toThrow(); + }); - const operations = [ - { path: "/sign-up/email" }, - { path: "/sign-in/email" }, - { path: "/sign-out" }, - ]; - - const promises = operations.map(async (op) => { - const hook = plugin.hooks?.before?.find(h => { - return h.matcher({ path: op.path, request: {} } as any); - }); - - const ctx = { - path: op.path, - body: { email: "test@example.com" }, - request: {}, - returned: { status: 200 }, - }; - - await hook?.handler(ctx as any); - await plugin.hooks?.after?.[0].handler(ctx as any); - - return ctx; - }); - - const results = await Promise.all(promises); - expect(results).toHaveLength(3); + it("should not throw when created with empty config", () => { + expect(() => otelPlugin({})).not.toThrow(); }); }); diff --git a/packages/otel-better-auth/src/index.ts b/packages/otel-better-auth/src/index.ts index 5af2e0e..f4d4a5c 100644 --- a/packages/otel-better-auth/src/index.ts +++ b/packages/otel-better-auth/src/index.ts @@ -29,56 +29,18 @@ export interface OtelBetterAuthConfig { */ tracerName?: string; - /** - * Whether to capture user email in spans. - * Defaults to false for privacy. - */ - captureEmail?: boolean; - - /** - * Whether to capture detailed error messages in spans. - * Defaults to true. - */ - captureErrors?: boolean; - /** * Custom tracer instance. If not provided, will use trace.getTracer(). */ tracer?: Tracer; } -/** - * Finalizes a span with status, timing, and optional error. - */ -function finalizeSpan(span: Span, error?: unknown, success = true): void { - span.setAttribute(SEMATTRS_AUTH_SUCCESS, success); - - if (error) { - if (error instanceof Error) { - span.recordException(error); - span.setAttribute(SEMATTRS_AUTH_ERROR, error.message); - } else { - const errorMsg = String(error); - span.recordException(new Error(errorMsg)); - span.setAttribute(SEMATTRS_AUTH_ERROR, errorMsg); - } - span.setStatus({ code: SpanStatusCode.ERROR }); - } else { - span.setStatus({ code: SpanStatusCode.OK }); - } - span.end(); -} +// Store spans per request +const requestSpans = new Map(); /** * Creates a Better Auth plugin that adds OpenTelemetry tracing to all auth operations. * - * This plugin automatically instruments key authentication events including: - * - User signup (password, OAuth, magic link, etc.) - * - User signin (all methods) - * - Password reset flows - * - Email verification - * - Session creation and management - * * @param config - Optional configuration for instrumentation behavior * @returns A Better Auth plugin that can be added via .use() * @@ -89,24 +51,12 @@ function finalizeSpan(span: Span, error?: unknown, success = true): void { * * export const auth = betterAuth({ * database: db, - * // ... other config * }).use(otelPlugin()); - * - * // Or with custom configuration - * export const auth = betterAuth({ - * database: db, - * }).use(otelPlugin({ - * tracerName: "my-app-auth", - * captureEmail: true, - * captureErrors: true, - * })); * ``` */ export function otelPlugin(config?: OtelBetterAuthConfig): BetterAuthPlugin { const { tracerName = DEFAULT_TRACER_NAME, - captureEmail = false, - captureErrors = true, tracer: customTracer, } = config ?? {}; @@ -114,235 +64,102 @@ export function otelPlugin(config?: OtelBetterAuthConfig): BetterAuthPlugin { return { id: "otel", - hooks: { - before: [ - { - matcher: (ctx) => { - // Match signup endpoints - return ( - ctx.path === "/sign-up/email" || - ctx.path === "/sign-up" || - ctx.request?.url?.includes("/sign-up") - ); - }, - handler: async (ctx) => { - const attributes: Record = { - [SEMATTRS_AUTH_OPERATION]: "signup", - [SEMATTRS_AUTH_METHOD]: "password", - }; + + onRequest: async (request) => { + try { + const url = new URL(request.url); + const path = url.pathname; - if (captureEmail && (ctx.body as any)?.email) { - attributes[SEMATTRS_USER_EMAIL] = (ctx.body as any).email; - } + let spanName: string | null = null; + const attributes: Record = {}; - const span = tracer.startSpan("auth.signup.email", { - kind: SpanKind.INTERNAL, - attributes, - }); + // Determine operation type from path + if (path.endsWith("/sign-up/email")) { + spanName = "auth.signup.email"; + attributes[SEMATTRS_AUTH_OPERATION] = "signup"; + attributes[SEMATTRS_AUTH_METHOD] = "password"; + } else if (path.endsWith("/sign-in/email")) { + spanName = "auth.signin.email"; + attributes[SEMATTRS_AUTH_OPERATION] = "signin"; + attributes[SEMATTRS_AUTH_METHOD] = "password"; + } else if (path.includes("/callback/")) { + const provider = path.split("/callback/")[1]?.split("/")[0]?.split("?")[0]; + if (provider) { + spanName = `auth.oauth.${provider}`; + attributes[SEMATTRS_AUTH_OPERATION] = "oauth_callback"; + attributes[SEMATTRS_AUTH_METHOD] = "oauth"; + attributes[SEMATTRS_AUTH_PROVIDER] = provider; + } + } else if (path.endsWith("/forget-password")) { + spanName = "auth.forgot_password"; + attributes[SEMATTRS_AUTH_OPERATION] = "forgot_password"; + } else if (path.endsWith("/reset-password")) { + spanName = "auth.reset_password"; + attributes[SEMATTRS_AUTH_OPERATION] = "reset_password"; + } else if (path.endsWith("/verify-email")) { + spanName = "auth.verify_email"; + attributes[SEMATTRS_AUTH_OPERATION] = "verify_email"; + } else if (path.endsWith("/sign-out")) { + spanName = "auth.signout"; + attributes[SEMATTRS_AUTH_OPERATION] = "signout"; + } else if (path.includes("/sign-in/") && !path.endsWith("/sign-in/email")) { + // OAuth initiation + const pathParts = path.split("/sign-in/"); + const provider = pathParts[1]?.split("/")[0]?.split("?")[0]; + if (provider && provider !== "email") { + spanName = `auth.oauth.${provider}.initiate`; + attributes[SEMATTRS_AUTH_OPERATION] = "oauth_initiate"; + attributes[SEMATTRS_AUTH_METHOD] = "oauth"; + attributes[SEMATTRS_AUTH_PROVIDER] = provider; + } + } - const activeContext = trace.setSpan(context.active(), span); - (ctx as any).__otelSpan = span; - (ctx as any).__otelContext = activeContext; + if (spanName) { + const span = tracer.startSpan(spanName, { + kind: SpanKind.INTERNAL, + attributes, + }); - return ctx; - }, - }, - { - matcher: (ctx) => { - // Match signin endpoints - return ( - ctx.path === "/sign-in/email" || - ctx.path === "/sign-in" || - ctx.request?.url?.includes("/sign-in") - ); - }, - handler: async (ctx) => { - const attributes: Record = { - [SEMATTRS_AUTH_OPERATION]: "signin", - [SEMATTRS_AUTH_METHOD]: "password", - }; + const spanKey = `${request.method}:${request.url}`; + requestSpans.set(spanKey, span); - if (captureEmail && (ctx.body as any)?.email) { - attributes[SEMATTRS_USER_EMAIL] = (ctx.body as any).email; - } + context.with(trace.setSpan(context.active(), span), () => {}); + } + } catch (error) { + console.error("[otel-better-auth]", error); + } + }, - const span = tracer.startSpan("auth.signin.email", { - kind: SpanKind.INTERNAL, - attributes, - }); + onResponse: async (response) => { + try { + const url = response.url; + if (!url) return; + + let spanKey = `POST:${url}`; + let span = requestSpans.get(spanKey); + + if (!span) { + spanKey = `GET:${url}`; + span = requestSpans.get(spanKey); + } + + if (span) { + const success = response.status >= 200 && response.status < 400; + span.setAttribute(SEMATTRS_AUTH_SUCCESS, success); - const activeContext = trace.setSpan(context.active(), span); - (ctx as any).__otelSpan = span; - (ctx as any).__otelContext = activeContext; + if (success) { + span.setStatus({ code: SpanStatusCode.OK }); + } else { + span.setStatus({ code: SpanStatusCode.ERROR }); + span.setAttribute(SEMATTRS_AUTH_ERROR, `HTTP ${response.status}`); + } - return ctx; - }, - }, - { - matcher: (ctx) => { - // Match forgot password endpoints - return ( - ctx.path === "/forget-password" || - ctx.request?.url?.includes("/forget-password") - ); - }, - handler: async (ctx) => { - const attributes: Record = { - [SEMATTRS_AUTH_OPERATION]: "forgot_password", - }; - - if (captureEmail && (ctx.body as any)?.email) { - attributes[SEMATTRS_USER_EMAIL] = (ctx.body as any).email; - } - - const span = tracer.startSpan("auth.forgot_password", { - kind: SpanKind.INTERNAL, - attributes, - }); - - const activeContext = trace.setSpan(context.active(), span); - (ctx as any).__otelSpan = span; - (ctx as any).__otelContext = activeContext; - - return ctx; - }, - }, - { - matcher: (ctx) => { - // Match reset password endpoints - return ( - ctx.path === "/reset-password" || - ctx.request?.url?.includes("/reset-password") - ); - }, - handler: async (ctx) => { - const attributes: Record = { - [SEMATTRS_AUTH_OPERATION]: "reset_password", - }; - - const span = tracer.startSpan("auth.reset_password", { - kind: SpanKind.INTERNAL, - attributes, - }); - - const activeContext = trace.setSpan(context.active(), span); - (ctx as any).__otelSpan = span; - (ctx as any).__otelContext = activeContext; - - return ctx; - }, - }, - { - matcher: (ctx) => { - // Match signout endpoints - return ( - ctx.path === "/sign-out" || ctx.request?.url?.includes("/sign-out") - ); - }, - handler: async (ctx) => { - const attributes: Record = { - [SEMATTRS_AUTH_OPERATION]: "signout", - }; - - const span = tracer.startSpan("auth.signout", { - kind: SpanKind.INTERNAL, - attributes, - }); - - const activeContext = trace.setSpan(context.active(), span); - (ctx as any).__otelSpan = span; - (ctx as any).__otelContext = activeContext; - - return ctx; - }, - }, - { - matcher: (ctx) => { - // Match verify email endpoints - return ( - ctx.path === "/verify-email" || - ctx.request?.url?.includes("/verify-email") - ); - }, - handler: async (ctx) => { - const attributes: Record = { - [SEMATTRS_AUTH_OPERATION]: "verify_email", - }; - - const span = tracer.startSpan("auth.verify_email", { - kind: SpanKind.INTERNAL, - attributes, - }); - - const activeContext = trace.setSpan(context.active(), span); - (ctx as any).__otelSpan = span; - (ctx as any).__otelContext = activeContext; - - return ctx; - }, - }, - { - matcher: (ctx) => { - // Match OAuth callback endpoints - return ( - (ctx as any).path?.includes("/callback/") || - ctx.request?.url?.includes("/callback/") - ); - }, - handler: async (ctx) => { - const url = ctx.request?.url || (ctx as any).path; - const provider = url?.split("/callback/")[1]?.split("/")[0]?.split("?")[0]; - - if (provider) { - const attributes: Record = { - [SEMATTRS_AUTH_OPERATION]: "signin", - [SEMATTRS_AUTH_METHOD]: "oauth", - [SEMATTRS_AUTH_PROVIDER]: provider, - }; - - const span = tracer.startSpan(`auth.oauth.${provider}`, { - kind: SpanKind.INTERNAL, - attributes, - }); - - const activeContext = trace.setSpan(context.active(), span); - (ctx as any).__otelSpan = span; - (ctx as any).__otelContext = activeContext; - } - - return ctx; - }, - }, - ], - after: [ - { - matcher: () => true, // Match all requests - handler: async (ctx) => { - const span = (ctx as any).__otelSpan; - if (span) { - const ctxAny = ctx as any; - const success = - !ctxAny.error && - (!ctxAny.returned || - (ctxAny.returned.status >= 200 && ctxAny.returned.status < 300)); - - // Add user/session info if available - if (ctxAny.context?.session?.userId) { - span.setAttribute(SEMATTRS_USER_ID, ctxAny.context.session.userId); - } - if (ctxAny.context?.session?.sessionId) { - span.setAttribute(SEMATTRS_SESSION_ID, ctxAny.context.session.sessionId); - } - - finalizeSpan(span, ctxAny.error, success); - delete (ctx as any).__otelSpan; - delete (ctx as any).__otelContext; - } - - return ctx; - }, - }, - ], + span.end(); + requestSpans.delete(spanKey); + } + } catch (error) { + console.error("[otel-better-auth]", error); + } }, }; }