Implement structured logging for API routes (P3.7)

Replace console.error with pino structured logger across API routes
and add key event logging per observability spec:
- Auth failure (warn): reason
- Period logged (info): userId, date
- Override toggled (info): userId, override, enabled
- Decision calculated (info): userId, decision, reason
- Error events (error): err object with stack trace

Files updated:
- auth-middleware.ts: Added structured logging for auth failures
- cycle/period/route.ts: Added Period logged event + error logging
- calendar/[userId]/[token].ics/route.ts: Replaced console.error
- overrides/route.ts: Added Override toggled events
- today/route.ts: Added Decision calculated event

Tests: 720 passing (added 3 new structured logging tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 09:19:55 +00:00
parent 00d902a396
commit 714194f2d3
7 changed files with 124 additions and 12 deletions

View File

@@ -3,6 +3,7 @@
import { type NextRequest, NextResponse } from "next/server";
import { generateIcsFeed } from "@/lib/ics";
import { logger } from "@/lib/logger";
import { createPocketBaseClient } from "@/lib/pocketbase";
interface RouteParams {
@@ -61,8 +62,7 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Re-throw unexpected errors
console.error("Calendar feed error:", error);
logger.error({ err: error, userId }, "Calendar feed error");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },

View File

@@ -5,6 +5,7 @@ import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-middleware";
import { getCycleDay, getPhase } from "@/lib/cycle";
import { logger } from "@/lib/logger";
import { createPocketBaseClient } from "@/lib/pocketbase";
interface PeriodLogRequest {
@@ -80,6 +81,9 @@ export const POST = withAuth(async (request: NextRequest, user) => {
const cycleDay = getCycleDay(lastPeriodDate, user.cycleLength, new Date());
const phase = getPhase(cycleDay);
// Log successful period logging per observability spec
logger.info({ userId: user.id, date: body.startDate }, "Period logged");
return NextResponse.json({
message: "Period start date logged successfully",
lastPeriodDate: body.startDate,
@@ -87,7 +91,7 @@ export const POST = withAuth(async (request: NextRequest, user) => {
phase,
});
} catch (error) {
console.error("Period logging error:", error);
logger.error({ err: error, userId: user.id }, "Period logging error");
return NextResponse.json(
{ error: "Failed to update period date" },
{ status: 500 },

View File

@@ -4,6 +4,7 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-middleware";
import { logger } from "@/lib/logger";
import { createPocketBaseClient } from "@/lib/pocketbase";
import type { OverrideType } from "@/types";
@@ -59,6 +60,12 @@ export const POST = withAuth(async (request: NextRequest, user) => {
.collection("users")
.update(user.id, { activeOverrides: newOverrides });
// Log override toggle per observability spec
logger.info(
{ userId: user.id, override: overrideToAdd, enabled: true },
"Override toggled",
);
return NextResponse.json({ activeOverrides: newOverrides });
});
@@ -98,5 +105,11 @@ export const DELETE = withAuth(async (request: NextRequest, user) => {
.collection("users")
.update(user.id, { activeOverrides: newOverrides });
// Log override toggle per observability spec
logger.info(
{ userId: user.id, override: overrideToRemove, enabled: false },
"Override toggled",
);
return NextResponse.json({ activeOverrides: newOverrides });
});

View File

@@ -11,6 +11,7 @@ import {
PHASE_CONFIGS,
} from "@/lib/cycle";
import { getDecisionWithOverrides } from "@/lib/decision-engine";
import { logger } from "@/lib/logger";
import { getNutritionGuidance } from "@/lib/nutrition";
import { createPocketBaseClient } from "@/lib/pocketbase";
import type { DailyData, DailyLog, HrvStatus } from "@/types";
@@ -99,6 +100,12 @@ export const GET = withAuth(async (_request, user) => {
// Get training decision with override handling
const decision = getDecisionWithOverrides(dailyData, user.activeOverrides);
// Log decision calculation per observability spec
logger.info(
{ userId: user.id, decision: decision.status, reason: decision.reason },
"Decision calculated",
);
// Get nutrition guidance
const nutrition = getNutritionGuidance(cycleDay);

View File

@@ -18,7 +18,17 @@ vi.mock("next/headers", () => ({
cookies: vi.fn(),
}));
// Mock the logger module
vi.mock("./logger", () => ({
logger: {
warn: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}));
import { cookies } from "next/headers";
import { logger } from "./logger";
import {
createPocketBaseClient,
getCurrentUser,
@@ -26,6 +36,12 @@ import {
loadAuthFromCookies,
} from "./pocketbase";
const mockLogger = logger as {
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;
};
const mockCookies = cookies as ReturnType<typeof vi.fn>;
const mockCreatePocketBaseClient = createPocketBaseClient as ReturnType<
typeof vi.fn
@@ -161,4 +177,59 @@ describe("withAuth", () => {
const body = await response.json();
expect(body.error).toBe("Internal server error");
});
describe("structured logging", () => {
beforeEach(() => {
mockLogger.warn.mockClear();
mockLogger.error.mockClear();
mockLogger.info.mockClear();
});
it("logs auth failure with warn level", async () => {
mockIsAuthenticated.mockReturnValue(false);
const handler = vi.fn();
const wrappedHandler = withAuth(handler);
await wrappedHandler({} as NextRequest);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.objectContaining({ reason: "not_authenticated" }),
expect.stringContaining("Auth failure"),
);
});
it("logs auth failure when getCurrentUser returns null", async () => {
mockIsAuthenticated.mockReturnValue(true);
mockGetCurrentUser.mockReturnValue(null);
const handler = vi.fn();
const wrappedHandler = withAuth(handler);
await wrappedHandler({} as NextRequest);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.objectContaining({ reason: "user_not_found" }),
expect.stringContaining("Auth failure"),
);
});
it("logs internal errors with error level and stack trace", async () => {
mockIsAuthenticated.mockReturnValue(true);
mockGetCurrentUser.mockReturnValue(mockUser);
const testError = new Error("Handler error");
const handler = vi.fn().mockRejectedValue(testError);
const wrappedHandler = withAuth(handler);
await wrappedHandler({} as NextRequest);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: testError,
}),
expect.stringContaining("Auth middleware error"),
);
});
});
});

View File

@@ -7,6 +7,7 @@ import { NextResponse } from "next/server";
import type { User } from "@/types";
import { logger } from "./logger";
import {
createPocketBaseClient,
getCurrentUser,
@@ -54,19 +55,21 @@ export function withAuth<T = unknown>(
// Check if the user is authenticated
if (!isAuthenticated(pb)) {
logger.warn({ reason: "not_authenticated" }, "Auth failure");
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get the current user
const user = getCurrentUser(pb);
if (!user) {
logger.warn({ reason: "user_not_found" }, "Auth failure");
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Call the original handler with the user context
return await handler(request, user, context);
} catch (error) {
console.error("Auth middleware error:", error);
logger.error({ err: error }, "Auth middleware error");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },