This commit is contained in:
Alex Holovach
2025-10-02 19:44:20 -05:00
parent 024f3a06ff
commit dc33c44519
3 changed files with 195 additions and 817 deletions

View File

@@ -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)

View File

@@ -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();
});
});

View File

@@ -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<string, Span>();
/**
* 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<string, string | boolean> = {
[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<string, string> = {};
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<string, string | boolean> = {
[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<string, string> = {
[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<string, string> = {
[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<string, string> = {
[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<string, string> = {
[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<string, string> = {
[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);
}
},
};
}