Merge pull request #199 from community-scripts/fix/198
fix/198: logging improvements and API handlers
This commit is contained in:
@@ -8,12 +8,15 @@ import stripAnsi from 'strip-ansi';
|
|||||||
import { spawn as ptySpawn } from 'node-pty';
|
import { spawn as ptySpawn } from 'node-pty';
|
||||||
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
|
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
|
||||||
import { getDatabase } from './src/server/database-prisma.js';
|
import { getDatabase } from './src/server/database-prisma.js';
|
||||||
|
import { registerGlobalErrorHandlers } from './src/server/logging/globalHandlers.js';
|
||||||
|
|
||||||
const dev = process.env.NODE_ENV !== 'production';
|
const dev = process.env.NODE_ENV !== 'production';
|
||||||
const hostname = '0.0.0.0';
|
const hostname = '0.0.0.0';
|
||||||
const port = parseInt(process.env.PORT || '3000', 10);
|
const port = parseInt(process.env.PORT || '3000', 10);
|
||||||
|
|
||||||
const app = next({ dev, hostname, port });
|
const app = next({ dev, hostname, port });
|
||||||
|
// Register global handlers once at bootstrap
|
||||||
|
registerGlobalErrorHandlers();
|
||||||
const handle = app.getRequestHandler();
|
const handle = app.getRequestHandler();
|
||||||
|
|
||||||
// WebSocket handler for script execution
|
// WebSocket handler for script execution
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import type { NextRequest } from 'next/server';
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '../../../../server/database-prisma';
|
import { getDatabase } from '../../../../server/database-prisma';
|
||||||
import type { CreateServerData } from '../../../../types/server';
|
import type { CreateServerData } from '../../../../types/server';
|
||||||
|
import { withApiLogging } from '../../../../server/logging/withApiLogging';
|
||||||
|
|
||||||
export async function GET(
|
export const GET = withApiLogging(async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
@@ -28,16 +29,16 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(server);
|
return NextResponse.json(server);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error fetching server:', error);
|
// Error handled by withApiLogging
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to fetch server' },
|
{ error: 'Failed to fetch server' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}, { redactBody: true });
|
||||||
|
|
||||||
export async function PUT(
|
export const PUT = withApiLogging(async function PUT(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
@@ -62,8 +63,9 @@ export async function PUT(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate SSH port
|
// Coerce and validate SSH port
|
||||||
if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) {
|
const port = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : 22;
|
||||||
|
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'SSH port must be between 1 and 65535' },
|
{ error: 'SSH port must be between 1 and 65535' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -111,7 +113,7 @@ export async function PUT(
|
|||||||
auth_type: authType,
|
auth_type: authType,
|
||||||
ssh_key,
|
ssh_key,
|
||||||
ssh_key_passphrase,
|
ssh_key_passphrase,
|
||||||
ssh_port: ssh_port ?? 22,
|
ssh_port: port,
|
||||||
color,
|
color,
|
||||||
key_generated: key_generated ?? false,
|
key_generated: key_generated ?? false,
|
||||||
ssh_key_path
|
ssh_key_path
|
||||||
@@ -124,7 +126,7 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating server:', error);
|
// Error handled by withApiLogging
|
||||||
|
|
||||||
// Handle unique constraint violation
|
// Handle unique constraint violation
|
||||||
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
|
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
|
||||||
@@ -139,9 +141,9 @@ export async function PUT(
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}, { redactBody: true });
|
||||||
|
|
||||||
export async function DELETE(
|
export const DELETE = withApiLogging(async function DELETE(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
@@ -177,12 +179,12 @@ export async function DELETE(
|
|||||||
changes: 1
|
changes: 1
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error deleting server:', error);
|
// Error handled by withApiLogging
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to delete server' },
|
{ error: 'Failed to delete server' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}, { redactBody: true });
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import type { NextRequest } from 'next/server';
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getSSHService } from '../../../../server/ssh-service';
|
import { getSSHService } from '../../../../server/ssh-service';
|
||||||
import { getDatabase } from '../../../../server/database-prisma';
|
import { getDatabase } from '../../../../server/database-prisma';
|
||||||
|
import { withApiLogging } from '../../../../server/logging/withApiLogging';
|
||||||
|
|
||||||
export async function POST(_request: NextRequest) {
|
export const POST = withApiLogging(async function POST(_request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const sshService = getSSHService();
|
const sshService = getSSHService();
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
@@ -20,7 +21,7 @@ export async function POST(_request: NextRequest) {
|
|||||||
serverId: serverId
|
serverId: serverId
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating SSH key pair:', error);
|
// Error handled by withApiLogging
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
@@ -29,4 +30,4 @@ export async function POST(_request: NextRequest) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}, { redactBody: true });
|
||||||
|
|||||||
@@ -2,22 +2,23 @@ import type { NextRequest } from 'next/server';
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '../../../server/database-prisma';
|
import { getDatabase } from '../../../server/database-prisma';
|
||||||
import type { CreateServerData } from '../../../types/server';
|
import type { CreateServerData } from '../../../types/server';
|
||||||
|
import { withApiLogging } from '../../../server/logging/withApiLogging';
|
||||||
|
|
||||||
export async function GET() {
|
export const GET = withApiLogging(async function GET() {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const servers = await db.getAllServers();
|
const servers = await db.getAllServers();
|
||||||
return NextResponse.json(servers);
|
return NextResponse.json(servers);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error fetching servers:', error);
|
// Error handled by withApiLogging
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to fetch servers' },
|
{ error: 'Failed to fetch servers' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}, { redactBody: true });
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export const POST = withApiLogging(async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated, ssh_key_path }: CreateServerData = body;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated, ssh_key_path }: CreateServerData = body;
|
||||||
@@ -30,8 +31,9 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate SSH port
|
// Coerce and validate SSH port
|
||||||
if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) {
|
const port = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : 22;
|
||||||
|
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'SSH port must be between 1 and 65535' },
|
{ error: 'SSH port must be between 1 and 65535' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -69,7 +71,7 @@ export async function POST(request: NextRequest) {
|
|||||||
auth_type: authType,
|
auth_type: authType,
|
||||||
ssh_key,
|
ssh_key,
|
||||||
ssh_key_passphrase,
|
ssh_key_passphrase,
|
||||||
ssh_port: ssh_port ?? 22,
|
ssh_port: port,
|
||||||
color,
|
color,
|
||||||
key_generated: key_generated ?? false,
|
key_generated: key_generated ?? false,
|
||||||
ssh_key_path
|
ssh_key_path
|
||||||
@@ -82,11 +84,10 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
{ status: 201 }
|
{ status: 201 }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error creating server:', error);
|
// Error handled by withApiLogging
|
||||||
|
|
||||||
// Handle unique constraint violation
|
// Handle unique constraint violation
|
||||||
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
|
if (Error instanceof Error && Error.message.includes('UNIQUE constraint failed')) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'A server with this name already exists' },
|
{ error: 'A server with this name already exists' },
|
||||||
{ status: 409 }
|
{ status: 409 }
|
||||||
@@ -98,5 +99,5 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}, { redactBody: true });
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { NextResponse } from 'next/server';
|
|||||||
import { getAuthConfig, updateAuthCredentials, updateAuthEnabled } from '~/lib/auth';
|
import { getAuthConfig, updateAuthCredentials, updateAuthEnabled } from '~/lib/auth';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { withApiLogging } from '../../../../server/logging/withApiLogging';
|
||||||
|
|
||||||
export async function GET() {
|
export const GET = withApiLogging(async function GET() {
|
||||||
try {
|
try {
|
||||||
const authConfig = getAuthConfig();
|
const authConfig = getAuthConfig();
|
||||||
|
|
||||||
@@ -14,16 +15,16 @@ export async function GET() {
|
|||||||
hasCredentials: authConfig.hasCredentials,
|
hasCredentials: authConfig.hasCredentials,
|
||||||
setupCompleted: authConfig.setupCompleted,
|
setupCompleted: authConfig.setupCompleted,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error reading auth credentials:', error);
|
// Error handled by withApiLogging
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to read auth configuration' },
|
{ error: 'Failed to read auth configuration' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}, { redactBody: true });
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export const POST = withApiLogging(async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { username, password, enabled } = await request.json() as { username: string; password: string; enabled?: boolean };
|
const { username, password, enabled } = await request.json() as { username: string; password: string; enabled?: boolean };
|
||||||
|
|
||||||
@@ -54,16 +55,16 @@ export async function POST(request: NextRequest) {
|
|||||||
success: true,
|
success: true,
|
||||||
message: 'Authentication credentials updated successfully'
|
message: 'Authentication credentials updated successfully'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error updating auth credentials:', error);
|
// Error handled by withApiLogging
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to update auth credentials' },
|
{ error: 'Failed to update auth credentials' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}, { redactBody: true });
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export const PATCH = withApiLogging(async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { enabled } = await request.json() as { enabled: boolean };
|
const { enabled } = await request.json() as { enabled: boolean };
|
||||||
|
|
||||||
@@ -107,11 +108,11 @@ export async function PATCH(request: NextRequest) {
|
|||||||
success: true,
|
success: true,
|
||||||
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
|
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error updating auth enabled status:', error);
|
// Error handled by withApiLogging
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to update auth status' },
|
{ error: 'Failed to update auth status' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}, { redactBody: true });
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import type { NextRequest } from 'next/server';
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { withApiLogging } from '../../../../server/logging/withApiLogging';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export const POST = withApiLogging(async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { token } = await request.json();
|
const { token } = await request.json();
|
||||||
|
|
||||||
@@ -39,16 +40,16 @@ export async function POST(request: NextRequest) {
|
|||||||
fs.writeFileSync(envPath, envContent);
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, message: 'GitHub token saved successfully' });
|
return NextResponse.json({ success: true, message: 'GitHub token saved successfully' });
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error saving GitHub token:', error);
|
// Error handled by withApiLogging
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to save GitHub token' },
|
{ error: 'Failed to save GitHub token' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}, { redactBody: true });
|
||||||
|
|
||||||
export async function GET() {
|
export const GET = withApiLogging(async function GET() {
|
||||||
try {
|
try {
|
||||||
// Path to the .env file
|
// Path to the .env file
|
||||||
const envPath = path.join(process.cwd(), '.env');
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
@@ -65,11 +66,11 @@ export async function GET() {
|
|||||||
const token = githubTokenMatch ? githubTokenMatch[1] : null;
|
const token = githubTokenMatch ? githubTokenMatch[1] : null;
|
||||||
|
|
||||||
return NextResponse.json({ token });
|
return NextResponse.json({ token });
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error reading GitHub token:', error);
|
// Error handled by withApiLogging
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to read GitHub token' },
|
{ error: 'Failed to read GitHub token' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}, { redactBody: true });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { type NextRequest } from "next/server";
|
|||||||
import { env } from "~/env.js";
|
import { env } from "~/env.js";
|
||||||
import { appRouter } from "~/server/api/root";
|
import { appRouter } from "~/server/api/root";
|
||||||
import { createTRPCContext } from "~/server/api/trpc";
|
import { createTRPCContext } from "~/server/api/trpc";
|
||||||
|
import logger from "../../../../server/logging/logger";
|
||||||
|
|
||||||
const handler = (req: NextRequest) =>
|
const handler = (req: NextRequest) =>
|
||||||
fetchRequestHandler({
|
fetchRequestHandler({
|
||||||
@@ -14,9 +15,7 @@ const handler = (req: NextRequest) =>
|
|||||||
onError:
|
onError:
|
||||||
env.NODE_ENV === "development"
|
env.NODE_ENV === "development"
|
||||||
? ({ path, error }) => {
|
? ({ path, error }) => {
|
||||||
console.error(
|
logger.error("trpc_error", { path: path ?? "<no-path>" }, error);
|
||||||
`[ERROR] tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const globalForPrisma = globalThis as unknown as {
|
|||||||
prisma: PrismaClient | undefined;
|
prisma: PrismaClient | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
||||||
|
log: ['warn', 'error']
|
||||||
|
});
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||||
|
|||||||
21
src/server/logging/globalHandlers.ts
Normal file
21
src/server/logging/globalHandlers.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import logger from './logger';
|
||||||
|
import { toSafeError } from './prismaSafeError';
|
||||||
|
|
||||||
|
let registered = false;
|
||||||
|
|
||||||
|
export function registerGlobalErrorHandlers() {
|
||||||
|
if (registered) return;
|
||||||
|
registered = true;
|
||||||
|
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
const safe = toSafeError(err);
|
||||||
|
logger.error('uncaught_exception', { name: safe.name, code: safe.code }, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
const safe = toSafeError(reason as any);
|
||||||
|
logger.error('unhandled_rejection', { name: safe.name, code: safe.code }, reason);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
81
src/server/logging/logger.ts
Normal file
81
src/server/logging/logger.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { redactObject } from './redact';
|
||||||
|
|
||||||
|
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||||
|
|
||||||
|
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||||
|
debug: 10,
|
||||||
|
info: 20,
|
||||||
|
warn: 30,
|
||||||
|
error: 40
|
||||||
|
};
|
||||||
|
|
||||||
|
const envLevel = (process.env.LOG_LEVEL as LogLevel) || 'info';
|
||||||
|
const currentLevel = LOG_LEVELS[envLevel] ?? LOG_LEVELS.info;
|
||||||
|
|
||||||
|
function safeMeta(meta?: unknown): unknown {
|
||||||
|
if (meta === undefined) return undefined;
|
||||||
|
try {
|
||||||
|
return redactObject(meta);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeError(err: unknown): { name?: string; code?: string; stack?: string } | undefined {
|
||||||
|
if (!err) return undefined;
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return {
|
||||||
|
name: err.name,
|
||||||
|
code: (err as any).code,
|
||||||
|
stack: formatStack(err.stack)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStack(stack?: string): string | undefined {
|
||||||
|
if (!stack) return undefined;
|
||||||
|
const lines = stack.split('\n').slice(0, 10);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(level: LogLevel, message: string, meta?: unknown, err?: unknown) {
|
||||||
|
if (LOG_LEVELS[level] < currentLevel) return;
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
level,
|
||||||
|
msg: message,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const redactedMeta = safeMeta(meta);
|
||||||
|
if (redactedMeta !== undefined) payload.meta = redactedMeta;
|
||||||
|
const safeErr = safeError(err);
|
||||||
|
if (safeErr) payload.err = safeErr;
|
||||||
|
|
||||||
|
const line = JSON.stringify(payload);
|
||||||
|
if (level === 'error') {
|
||||||
|
console.error(line);
|
||||||
|
} else if (level === 'warn') {
|
||||||
|
console.warn(line);
|
||||||
|
} else {
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
debug(message: string, meta?: unknown) {
|
||||||
|
log('debug', message, meta);
|
||||||
|
},
|
||||||
|
info(message: string, meta?: unknown) {
|
||||||
|
log('info', message, meta);
|
||||||
|
},
|
||||||
|
warn(message: string, meta?: unknown) {
|
||||||
|
log('warn', message, meta);
|
||||||
|
},
|
||||||
|
error(message: string, meta?: unknown, err?: unknown) {
|
||||||
|
log('error', message, meta, err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default logger;
|
||||||
|
|
||||||
|
|
||||||
36
src/server/logging/prismaSafeError.ts
Normal file
36
src/server/logging/prismaSafeError.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
type SafeError = { code?: string; name: string; safeMessage: string };
|
||||||
|
|
||||||
|
export function toSafeError(err: unknown): SafeError {
|
||||||
|
if (err && typeof err === 'object') {
|
||||||
|
const name = (err as any).name as string | undefined;
|
||||||
|
const code = (err as any).code as string | undefined;
|
||||||
|
|
||||||
|
// Prisma error names to map
|
||||||
|
if (name === 'PrismaClientValidationError') {
|
||||||
|
return { name, code, safeMessage: 'Invalid input' };
|
||||||
|
}
|
||||||
|
if (name === 'PrismaClientKnownRequestError') {
|
||||||
|
// Avoid echoing message which may include parameters
|
||||||
|
return { name, code, safeMessage: 'Database constraint or known request error' };
|
||||||
|
}
|
||||||
|
if (name === 'PrismaClientUnknownRequestError') {
|
||||||
|
return { name, code, safeMessage: 'Database request failed' };
|
||||||
|
}
|
||||||
|
if (name === 'PrismaClientRustPanicError') {
|
||||||
|
return { name, code, safeMessage: 'Database engine error' };
|
||||||
|
}
|
||||||
|
if (name === 'PrismaClientInitializationError') {
|
||||||
|
return { name, code, safeMessage: 'Database initialization failed' };
|
||||||
|
}
|
||||||
|
if (name === 'PrismaClientFetchEngineError') {
|
||||||
|
return { name, code, safeMessage: 'Database engine fetch error' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
return { name, code, safeMessage: 'Unhandled server error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { name: 'Error', safeMessage: 'Unhandled server error' };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
86
src/server/logging/redact.ts
Normal file
86
src/server/logging/redact.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
const DEFAULT_MASK = '***REDACTED***';
|
||||||
|
|
||||||
|
const DEFAULT_SENSITIVE_KEYS = [
|
||||||
|
'password',
|
||||||
|
'ssh_key',
|
||||||
|
'sshKey',
|
||||||
|
'ssh_key_passphrase',
|
||||||
|
'sshKeyPassphrase',
|
||||||
|
'token',
|
||||||
|
'access_token',
|
||||||
|
'refresh_token',
|
||||||
|
'authorization',
|
||||||
|
'cookie',
|
||||||
|
'set-cookie',
|
||||||
|
'secret',
|
||||||
|
'apiKey',
|
||||||
|
'apikey'
|
||||||
|
];
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Object.prototype.toString.call(value) === '[object Object]';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactObject(
|
||||||
|
value: unknown,
|
||||||
|
opts?: { keys?: string[]; mask?: string }
|
||||||
|
): unknown {
|
||||||
|
const keys = (opts?.keys ?? DEFAULT_SENSITIVE_KEYS).map(k => k.toLowerCase());
|
||||||
|
const mask = opts?.mask ?? DEFAULT_MASK;
|
||||||
|
|
||||||
|
const visit = (val: unknown): unknown => {
|
||||||
|
if (val === null || val === undefined) return val;
|
||||||
|
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') return val;
|
||||||
|
if (Array.isArray(val)) return val.map(visit);
|
||||||
|
if (val instanceof Map) {
|
||||||
|
const mapped = new Map();
|
||||||
|
for (const [k, v] of val.entries()) {
|
||||||
|
const shouldRedact = typeof k === 'string' && keys.includes(k.toLowerCase());
|
||||||
|
mapped.set(k, shouldRedact ? mask : visit(v));
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
if (isPlainObject(val)) {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(val)) {
|
||||||
|
const shouldRedact = keys.includes(k.toLowerCase());
|
||||||
|
out[k] = shouldRedact ? mask : visit(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return maskIfLikelySecret(val, mask);
|
||||||
|
};
|
||||||
|
|
||||||
|
return visit(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskIfLikelySecret(val: unknown, mask: string): unknown {
|
||||||
|
// For safety, non-serializable or unexpected values get masked when logged
|
||||||
|
try {
|
||||||
|
JSON.stringify(val);
|
||||||
|
return val;
|
||||||
|
} catch {
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeBody(body: unknown): { keys: string[]; size?: number } {
|
||||||
|
try {
|
||||||
|
if (isPlainObject(body)) {
|
||||||
|
const keys = Object.keys(body);
|
||||||
|
const size = Buffer.from(JSON.stringify(body)).length;
|
||||||
|
return { keys, size };
|
||||||
|
}
|
||||||
|
if (Array.isArray(body)) {
|
||||||
|
const size = Buffer.from(JSON.stringify(body)).length;
|
||||||
|
return { keys: ['<array>'], size };
|
||||||
|
}
|
||||||
|
return { keys: [typeof body], size: undefined };
|
||||||
|
} catch {
|
||||||
|
return { keys: ['<unserializable>'] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SENSITIVE_KEYS = DEFAULT_SENSITIVE_KEYS;
|
||||||
|
|
||||||
|
|
||||||
48
src/server/logging/withApiLogging.ts
Normal file
48
src/server/logging/withApiLogging.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import logger from './logger';
|
||||||
|
import { redactObject, summarizeBody } from './redact';
|
||||||
|
import { toSafeError } from './prismaSafeError';
|
||||||
|
|
||||||
|
type Handler = (request: NextRequest, context?: any) => Promise<Response> | Response;
|
||||||
|
|
||||||
|
export function withApiLogging(
|
||||||
|
handler: Handler,
|
||||||
|
opts?: { redactBody?: boolean }
|
||||||
|
) {
|
||||||
|
const { redactBody = false } = opts ?? {};
|
||||||
|
|
||||||
|
return async function wrapped(request: NextRequest, context?: any) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const method = request.method;
|
||||||
|
const path = url.pathname;
|
||||||
|
const queryKeys = Array.from(url.searchParams.keys());
|
||||||
|
|
||||||
|
try {
|
||||||
|
let meta: Record<string, unknown> = { method, path, queryKeys };
|
||||||
|
if (method !== 'GET' && method !== 'HEAD') {
|
||||||
|
try {
|
||||||
|
const body = await request.clone().json();
|
||||||
|
meta = {
|
||||||
|
...meta,
|
||||||
|
body: redactBody ? undefined : redactObject(body),
|
||||||
|
bodySummary: redactBody ? summarizeBody(body) : undefined
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON bodies
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info('api_request', meta);
|
||||||
|
|
||||||
|
const response = await handler(request, context);
|
||||||
|
logger.info('api_response', { method, path, status: response.status });
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
const safe = toSafeError(err);
|
||||||
|
logger.error('api_error', { method, path, code: safe.code, name: safe.name }, err);
|
||||||
|
return NextResponse.json({ error: safe.safeMessage }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user