mirror of
https://github.com/zoriya/drizzle-otel.git
synced 2025-12-06 00:46:09 +00:00
simplify
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user