diff --git a/apps/web/__tests__/ai-detect-recurring-pattern.test.ts b/apps/web/__tests__/ai-detect-recurring-pattern.test.ts index 220d257acd..bfd7b10df7 100644 --- a/apps/web/__tests__/ai-detect-recurring-pattern.test.ts +++ b/apps/web/__tests__/ai-detect-recurring-pattern.test.ts @@ -11,12 +11,6 @@ import { getEmailAccount } from "@/__tests__/helpers"; const TIMEOUT = 15_000; vi.mock("server-only", () => ({})); -vi.mock("@/utils/logger", () => ({ - createScopedLogger: () => ({ - trace: vi.fn(), - error: vi.fn(), - }), -})); vi.mock("@/utils/braintrust", () => ({ Braintrust: class { insertToDataset() {} diff --git a/apps/web/__tests__/setup.ts b/apps/web/__tests__/setup.ts new file mode 100644 index 0000000000..ac4279209b --- /dev/null +++ b/apps/web/__tests__/setup.ts @@ -0,0 +1,13 @@ +import { vi } from "vitest"; + +// Mock next/server's after() to just run synchronously in tests +vi.mock("next/server", async () => { + const actual = await vi.importActual("next/server"); + return { + ...actual, + after: async (fn: () => void | Promise) => { + // In tests, just run the function synchronously + return await fn(); + }, + }; +}); diff --git a/apps/web/app/api/ai/models/route.ts b/apps/web/app/api/ai/models/route.ts index cb37595f9b..9764bdfff9 100644 --- a/apps/web/app/api/ai/models/route.ts +++ b/apps/web/app/api/ai/models/route.ts @@ -3,9 +3,6 @@ import OpenAI from "openai"; import prisma from "@/utils/prisma"; import { withEmailAccount } from "@/utils/middleware"; import { Provider } from "@/utils/llms/config"; -import { createScopedLogger } from "@/utils/logger"; - -const logger = createScopedLogger("api/ai/models"); export type OpenAiModelsResponse = Awaited>; @@ -17,8 +14,8 @@ async function getOpenAiModels({ apiKey }: { apiKey: string }) { return models.data; } -export const GET = withEmailAccount(async (request) => { - const emailAccountId = request.auth.emailAccountId; +export const GET = withEmailAccount("api/ai/models", async (req) => { + const { emailAccountId } = req.auth; const emailAccount = await prisma.emailAccount.findUnique({ where: { id: emailAccountId }, @@ -38,7 +35,7 @@ export const GET = withEmailAccount(async (request) => { }); return NextResponse.json(result); } catch (error) { - logger.error("Failed to get OpenAI models", { error }); + req.logger.error("Failed to get OpenAI models", { error }); return NextResponse.json([]); } }); diff --git a/apps/web/app/api/sso/signin/route.test.ts b/apps/web/app/api/sso/signin/route.test.ts index 79ad92caf2..dc0a2afa1a 100644 --- a/apps/web/app/api/sso/signin/route.test.ts +++ b/apps/web/app/api/sso/signin/route.test.ts @@ -10,14 +10,6 @@ vi.mock("@/utils/auth", () => ({ }, })); -// Mock the logger -vi.mock("@/utils/logger", () => ({ - createScopedLogger: vi.fn(() => ({ - info: vi.fn(), - error: vi.fn(), - })), -})); - // Mock Prisma vi.mock("@/utils/prisma", () => ({ default: { @@ -30,12 +22,10 @@ vi.mock("@/utils/prisma", () => ({ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { betterAuthConfig } from "@/utils/auth"; -import { createScopedLogger } from "@/utils/logger"; import prisma from "@/utils/prisma"; import { GET } from "./route"; const mockBetterAuthConfig = vi.mocked(betterAuthConfig); -const mockLogger = vi.mocked(createScopedLogger); describe("SSO Signin Route", () => { const mockContext = { params: Promise.resolve({}) }; diff --git a/apps/web/utils/auth.test.ts b/apps/web/utils/auth.test.ts index 850144e687..f8dc2ac0cc 100644 --- a/apps/web/utils/auth.test.ts +++ b/apps/web/utils/auth.test.ts @@ -19,13 +19,6 @@ vi.mock("@/utils/error", () => ({ captureException: vi.fn(), })); -vi.mock("@/utils/logger", () => ({ - createScopedLogger: () => ({ - info: vi.fn(), - error: vi.fn(), - }), -})); - // Import the real function from auth.ts for testing describe("handleReferralOnSignUp", () => { diff --git a/apps/web/utils/cron.test.ts b/apps/web/utils/cron.test.ts index fd4ce400cc..301eb81e37 100644 --- a/apps/web/utils/cron.test.ts +++ b/apps/web/utils/cron.test.ts @@ -7,10 +7,6 @@ vi.mock("@/env", () => ({ env: { CRON_SECRET: "test-secret-123" }, })); -vi.mock("@/utils/logger", () => ({ - createScopedLogger: () => ({ error: vi.fn() }), -})); - describe("hasCronSecret", () => { let request: Request; diff --git a/apps/web/utils/encryption.test.ts b/apps/web/utils/encryption.test.ts index 663f21de4c..670a1c37ec 100644 --- a/apps/web/utils/encryption.test.ts +++ b/apps/web/utils/encryption.test.ts @@ -4,14 +4,6 @@ import { encryptToken, decryptToken } from "./encryption"; // Mock server-only as it's required for tests vi.mock("server-only", () => ({})); -// Mock the logger to prevent actual logging during tests -vi.mock("@/utils/logger", () => ({ - createScopedLogger: () => ({ - error: vi.fn(), - log: vi.fn(), - }), -})); - // Mock environment variables vi.mock("@/env", () => ({ env: { diff --git a/apps/web/utils/logger.ts b/apps/web/utils/logger.ts index 2a523c308a..bd87dd118d 100644 --- a/apps/web/utils/logger.ts +++ b/apps/web/utils/logger.ts @@ -73,6 +73,7 @@ export function createScopedLogger(scope: string) { }, with: (newFields: Record) => createLogger({ ...fields, ...newFields }), + flush: () => Promise.resolve(), // No-op for console logger }; }; @@ -103,6 +104,7 @@ function createAxiomLogger(scope: string) { }, with: (newFields: Record) => createLogger({ ...fields, ...newFields }), + flush: () => log.flush(), }); return createLogger(); @@ -115,6 +117,7 @@ function createNullLogger() { warn: () => {}, trace: () => {}, with: () => createNullLogger(), + flush: () => Promise.resolve(), }; } diff --git a/apps/web/utils/middleware.ts b/apps/web/utils/middleware.ts index 3b242d2fe9..30fc38ba97 100644 --- a/apps/web/utils/middleware.ts +++ b/apps/web/utils/middleware.ts @@ -1,9 +1,10 @@ import { ZodError } from "zod"; -import { type NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse, after } from "next/server"; +import { randomUUID } from "node:crypto"; import { captureException, checkCommonErrors, SafeError } from "@/utils/error"; import { env } from "@/env"; import { logErrorToPosthog } from "@/utils/error.server"; -import { createScopedLogger } from "@/utils/logger"; +import { createScopedLogger, type Logger } from "@/utils/logger"; import { auth } from "@/utils/auth"; import { getEmailAccount } from "@/utils/redis/account-validation"; import { getCallerEmailAccount } from "@/utils/organizations/access"; @@ -22,14 +23,16 @@ export type NextHandler = ( context: { params: Promise> }, ) => Promise; +interface RequestWithLogger extends NextRequest { + logger: Logger; +} + // Extended request type with validated account info -export interface RequestWithAuth extends NextRequest { - auth: { - userId: string; - }; +export interface RequestWithAuth extends RequestWithLogger { + auth: { userId: string }; } -export interface RequestWithEmailAccount extends NextRequest { +export interface RequestWithEmailAccount extends RequestWithLogger { auth: { userId: string; emailAccountId: string; @@ -52,16 +55,27 @@ function withMiddleware( options?: MiddlewareOptions, ) => Promise, options?: MiddlewareOptions, + scope?: string, ): NextHandler { return async (req, context) => { + const requestId = req.headers.get("x-request-id") || randomUUID(); + const baseLogger = createScopedLogger(scope || "api").with({ + requestId, + url: req.url, + }); + + const reqWithLogger = req as NextRequest & { logger?: Logger }; + reqWithLogger.logger = baseLogger; + try { // Apply middleware if provided - let enhancedReq = req; + let enhancedReq = reqWithLogger; if (middleware) { - const middlewareResult = await middleware(req, options); + const middlewareResult = await middleware(reqWithLogger, options); // If middleware returned a Response, return it directly if (middlewareResult instanceof Response) { + flushLogger(reqWithLogger); return middlewareResult; } @@ -70,8 +84,14 @@ function withMiddleware( } // Execute the handler with the (potentially) enhanced request - return await handler(enhancedReq as T, context); + const response = await handler(enhancedReq as T, context); + + flushLogger(enhancedReq); + + return response; } catch (error) { + flushLogger(reqWithLogger); + // redirects work by throwing an error. allow these if (error instanceof Error && error.message === "NEXT_REDIRECT") { throw error; @@ -90,9 +110,11 @@ function withMiddleware( } } + const reqLogger = getLogger(reqWithLogger); + if (error instanceof ZodError) { if (env.LOG_ZOD_ERRORS) { - logger.error("Error for url", { error, url: req.url }); + reqLogger.error("Zod validation error", { error }); } return NextResponse.json( { error: { issues: error.issues }, isKnownError: true }, @@ -127,9 +149,8 @@ function withMiddleware( console.error(error); } - logger.error("Unhandled error", { + reqLogger.error("Unhandled error", { error: error instanceof Error ? error.message : error, - url: req.url, }); captureException(error, { extra: { url: req.url } }); @@ -152,10 +173,12 @@ async function authMiddleware( ); } - // Create a new request with auth info const authReq = req.clone() as RequestWithAuth; authReq.auth = { userId: session.user.id }; + const baseLogger = getLogger(req); + authReq.logger = baseLogger.with({ userId: session.user.id }); + return authReq; } @@ -180,6 +203,8 @@ async function emailAccountMiddleware( // If account ID is provided, validate and get the email account ID const email = await getEmailAccount({ userId, emailAccountId }); + const emailAccountLogger = authReq.logger.with({ emailAccountId, email }); + if (!email && options?.allowOrgAdmins) { // Check if user is admin or owner and is in the same org as the target email account const callerEmailAccount = await getCallerEmailAccount( @@ -188,6 +213,7 @@ async function emailAccountMiddleware( ); if (!callerEmailAccount) { + emailAccountLogger.error("Org admin access denied"); return NextResponse.json( { error: "Insufficient permissions", isKnownError: true }, { status: 403 }, @@ -206,12 +232,16 @@ async function emailAccountMiddleware( emailAccountId, email: targetEmailAccount.email, }; + emailAccountReq.logger = emailAccountLogger.with({ + isOrgAdmin: true, + email: targetEmailAccount.email, + }); return emailAccountReq; } } if (!email) { - logger.error("Invalid account ID", { emailAccountId, userId }); + emailAccountLogger.error("Invalid email account ID"); return NextResponse.json( { error: "Invalid account ID", isKnownError: true }, { status: 403 }, @@ -221,6 +251,7 @@ async function emailAccountMiddleware( // Create a new request with email account info const emailAccountReq = req.clone() as RequestWithEmailAccount; emailAccountReq.auth = { userId, emailAccountId, email }; + emailAccountReq.logger = emailAccountLogger; return emailAccountReq; } @@ -264,10 +295,13 @@ async function emailProviderMiddleware( const providerReq = emailAccountReq.clone() as RequestWithEmailProvider; providerReq.auth = emailAccountReq.auth; providerReq.emailProvider = provider; + providerReq.logger = emailAccountReq.logger.with({ + provider: emailAccount.account.provider, + }); return providerReq; } catch (error) { - logger.error("Failed to create email provider", { + emailAccountReq.logger.error("Failed to create email provider", { error, emailAccountId, userId, @@ -280,28 +314,104 @@ async function emailProviderMiddleware( } // Public middlewares that build on the common infrastructure + +// withError overloads export function withError( + scope: string, handler: NextHandler, options?: MiddlewareOptions, +): NextHandler; +export function withError( + handler: NextHandler, + options?: MiddlewareOptions, +): NextHandler; +export function withError( + scopeOrHandler: string | NextHandler, + handlerOrOptions?: NextHandler | MiddlewareOptions, + options?: MiddlewareOptions, ): NextHandler { - return withMiddleware(handler, undefined, options); + if (typeof scopeOrHandler === "string") { + return withMiddleware( + handlerOrOptions as NextHandler, + undefined, + options, + scopeOrHandler, + ); + } + return withMiddleware( + scopeOrHandler, + undefined, + handlerOrOptions as MiddlewareOptions, + ); } -export function withAuth(handler: NextHandler): NextHandler { - return withMiddleware(handler, authMiddleware); +// withAuth overloads +export function withAuth( + scope: string, + handler: NextHandler, +): NextHandler; +export function withAuth(handler: NextHandler): NextHandler; +export function withAuth( + scopeOrHandler: string | NextHandler, + handler?: NextHandler, +): NextHandler { + if (typeof scopeOrHandler === "string") { + return withMiddleware(handler!, authMiddleware, undefined, scopeOrHandler); + } + return withMiddleware(scopeOrHandler, authMiddleware); } +// withEmailAccount overloads +export function withEmailAccount( + scope: string, + handler: NextHandler, + options?: MiddlewareOptions, +): NextHandler; export function withEmailAccount( handler: NextHandler, options?: MiddlewareOptions, +): NextHandler; +export function withEmailAccount( + scopeOrHandler: string | NextHandler, + handlerOrOptions?: NextHandler | MiddlewareOptions, + options?: MiddlewareOptions, ): NextHandler { - return withMiddleware(handler, emailAccountMiddleware, options); + if (typeof scopeOrHandler === "string") { + return withMiddleware( + handlerOrOptions as NextHandler, + emailAccountMiddleware, + options, + scopeOrHandler, + ); + } + return withMiddleware( + scopeOrHandler, + emailAccountMiddleware, + handlerOrOptions as MiddlewareOptions, + ); } +// withEmailProvider overloads export function withEmailProvider( + scope: string, handler: NextHandler, +): NextHandler; +export function withEmailProvider( + handler: NextHandler, +): NextHandler; +export function withEmailProvider( + scopeOrHandler: string | NextHandler, + handler?: NextHandler, ): NextHandler { - return withMiddleware(handler, emailProviderMiddleware); + if (typeof scopeOrHandler === "string") { + return withMiddleware( + handler!, + emailProviderMiddleware, + undefined, + scopeOrHandler, + ); + } + return withMiddleware(scopeOrHandler, emailProviderMiddleware); } function isErrorWithConfigAndHeaders( @@ -314,3 +424,20 @@ function isErrorWithConfigAndHeaders( "headers" in (error as { config: any }).config ); } + +function getLogger(req: NextRequest): Logger { + const reqWithLogger = req as RequestWithLogger; + return reqWithLogger.logger || logger; +} + +function flushLogger(req: NextRequest) { + const reqWithLogger = req as RequestWithLogger; + if (reqWithLogger.logger) { + const loggerToFlush = reqWithLogger.logger; + after(async () => { + await loggerToFlush.flush().catch((error) => { + captureException(error, { extra: { url: req.url } }); + }); + }); + } +} diff --git a/apps/web/vitest.config.mts b/apps/web/vitest.config.mts index 200d0d288a..01435a0757 100644 --- a/apps/web/vitest.config.mts +++ b/apps/web/vitest.config.mts @@ -6,6 +6,7 @@ export default defineConfig({ plugins: [tsconfigPaths()], test: { environment: "node", + setupFiles: ["./__tests__/setup.ts"], env: { ...config({ path: "./.env.test" }).parsed, },