Add production observability: Sentry, pino, and sharp badge encoding.
Local Build and Deploy / deploy (push) Successful in 1m9s
Local Build and Deploy / deploy (push) Successful in 1m9s
Sentry is opt-in via DSN env vars; logger uses structured pino JSON in prod; badge uploads are normalized to GIF with sharp. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
6c81af69ea
commit
09f1bc2bd6
16 files changed
+2206
-121
No files matched your search
+12
-1
@@ -76,5 +76,16 @@ PAYPAL_API=https://api-m.sandbox.paypal.com
|
|||||||
REDIS_URL=redis://127.0.0.1:6379
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
|
||||||
# Logging level (debug | info | warn | error). Defaults to 'info' in production,
|
# Logging level (debug | info | warn | error). Defaults to 'info' in production,
|
||||||
# 'debug' in development.
|
# 'debug' in development. Production logs use structured JSON via pino.
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Optional Sentry error monitoring (no-op when unset).
|
||||||
|
# Server/edge use SENTRY_DSN; browser uses NEXT_PUBLIC_SENTRY_DSN.
|
||||||
|
SENTRY_DSN=
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
|
# Optional source-map upload during CI builds (requires SENTRY_AUTH_TOKEN).
|
||||||
|
SENTRY_ORG=
|
||||||
|
SENTRY_PROJECT=
|
||||||
|
SENTRY_AUTH_TOKEN=
|
||||||
|
# Optional release tag shown in Sentry (e.g. git sha).
|
||||||
|
APP_VERSION=
|
||||||
+17
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import { withSentryConfig } from "@sentry/nextjs";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
const securityHeaders = [
|
const securityHeaders = [
|
||||||
@@ -38,6 +39,9 @@ const nextConfig: NextConfig = {
|
|||||||
"mariadb",
|
"mariadb",
|
||||||
"@prisma/client",
|
"@prisma/client",
|
||||||
"lzma",
|
"lzma",
|
||||||
|
"sharp",
|
||||||
|
"pino",
|
||||||
|
"pino-pretty",
|
||||||
],
|
],
|
||||||
|
|
||||||
// Compress responses with gzip
|
// Compress responses with gzip
|
||||||
@@ -79,4 +83,16 @@ const nextConfig: NextConfig = {
|
|||||||
// src/i18n/request.ts, so URLs and the access-guard middleware stay unchanged.
|
// src/i18n/request.ts, so URLs and the access-guard middleware stay unchanged.
|
||||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
const config = withNextIntl(nextConfig);
|
||||||
|
|
||||||
|
export default withSentryConfig(config, {
|
||||||
|
org: process.env.SENTRY_ORG,
|
||||||
|
project: process.env.SENTRY_PROJECT,
|
||||||
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
|
silent: !process.env.CI,
|
||||||
|
widenClientFileUpload: true,
|
||||||
|
disableLogger: true,
|
||||||
|
sourcemaps: {
|
||||||
|
disable: !process.env.SENTRY_AUTH_TOKEN,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@prisma/adapter-mariadb": "^7.8.0",
|
"@prisma/adapter-mariadb": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
|
"@sentry/nextjs": "^10.66.0",
|
||||||
"@tanstack/react-virtual": "^3.14.6",
|
"@tanstack/react-virtual": "^3.14.6",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -49,12 +50,14 @@
|
|||||||
"next-intl": "^4.13.2",
|
"next-intl": "^4.13.2",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
|
"pino": "^10.3.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.81.0",
|
"react-hook-form": "^7.81.0",
|
||||||
"resend": "^6.17.2",
|
"resend": "^6.17.2",
|
||||||
"sanitize-html": "^2.17.6",
|
"sanitize-html": "^2.17.6",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -71,6 +74,7 @@
|
|||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "^8.5.19",
|
"postcss": "^8.5.19",
|
||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
|
|||||||
Generated
+1947
-27
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,7 @@ onlyBuiltDependencies:
|
|||||||
- sharp
|
- sharp
|
||||||
- "@parcel/watcher"
|
- "@parcel/watcher"
|
||||||
- "@swc/core"
|
- "@swc/core"
|
||||||
|
- "@sentry/cli"
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
fast-uri: "^3.1.3"
|
fast-uri: "^3.1.3"
|
||||||
|
|||||||
+42
-17
@@ -1,5 +1,6 @@
|
|||||||
import { Cron } from "croner";
|
import { Cron } from "croner";
|
||||||
import { env } from "../src/env";
|
import { env } from "../src/env";
|
||||||
|
import { logger } from "../src/lib/logger";
|
||||||
import { prisma } from "../src/lib/prisma";
|
import { prisma } from "../src/lib/prisma";
|
||||||
|
|
||||||
async function backupEmulatorJar(): Promise<void> {
|
async function backupEmulatorJar(): Promise<void> {
|
||||||
@@ -21,9 +22,11 @@ async function backupEmulatorJar(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
copyFileSync(env.EMULATOR_JAR_PATH, backupFile);
|
copyFileSync(env.EMULATOR_JAR_PATH, backupFile);
|
||||||
console.log(`[jobs] Backed up emulator JAR to ${backupFile}`);
|
logger.info("Backed up emulator JAR", {
|
||||||
|
module: "jobs",
|
||||||
|
backupFile,
|
||||||
|
});
|
||||||
|
|
||||||
// Rotate: keep only the N newest
|
|
||||||
const keep = env.EMULATOR_BACKUP_KEEP ?? 7;
|
const keep = env.EMULATOR_BACKUP_KEEP ?? 7;
|
||||||
const files = readdirSync(env.EMULATOR_BACKUP_DIR)
|
const files = readdirSync(env.EMULATOR_BACKUP_DIR)
|
||||||
.filter((f) => f.startsWith("emulator-") && f.endsWith(".jar"))
|
.filter((f) => f.startsWith("emulator-") && f.endsWith(".jar"))
|
||||||
@@ -32,10 +35,16 @@ async function backupEmulatorJar(): Promise<void> {
|
|||||||
|
|
||||||
for (let i = keep; i < files.length; i++) {
|
for (let i = keep; i < files.length; i++) {
|
||||||
unlinkSync(resolve(env.EMULATOR_BACKUP_DIR, files[i]));
|
unlinkSync(resolve(env.EMULATOR_BACKUP_DIR, files[i]));
|
||||||
console.log(`[jobs] Rotated out old backup: ${files[i]}`);
|
logger.info("Rotated out old backup", {
|
||||||
|
module: "jobs",
|
||||||
|
file: files[i],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[jobs] JAR backup failed:", err);
|
logger.error("JAR backup failed", {
|
||||||
|
module: "jobs",
|
||||||
|
err: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,9 +54,12 @@ async function cleanupOldLogs(): Promise<void> {
|
|||||||
await prisma.websiteLoginLogs.deleteMany({
|
await prisma.websiteLoginLogs.deleteMany({
|
||||||
where: { createdAt: { lt: cutoff } },
|
where: { createdAt: { lt: cutoff } },
|
||||||
});
|
});
|
||||||
console.log("[jobs] Cleaned up login logs older than 30 days");
|
logger.info("Cleaned up login logs older than 30 days", { module: "jobs" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[jobs] Log cleanup failed:", err);
|
logger.error("Log cleanup failed", {
|
||||||
|
module: "jobs",
|
||||||
|
err: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,34 +69,44 @@ async function cleanupOldSessions(): Promise<void> {
|
|||||||
await prisma.passwordReset.deleteMany({
|
await prisma.passwordReset.deleteMany({
|
||||||
where: { createdAt: { lt: cutoff } },
|
where: { createdAt: { lt: cutoff } },
|
||||||
});
|
});
|
||||||
console.log("[jobs] Cleaned up expired password reset tokens");
|
logger.info("Cleaned up expired password reset tokens", {
|
||||||
|
module: "jobs",
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[jobs] Session cleanup failed:", err);
|
logger.error("Session cleanup failed", {
|
||||||
|
module: "jobs",
|
||||||
|
err: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log("[jobs] Worker started");
|
logger.info("Worker started", { module: "jobs" });
|
||||||
|
|
||||||
// JAR backup — daily at 03:00
|
|
||||||
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
|
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
|
||||||
new Cron("0 3 * * *", () => {
|
new Cron("0 3 * * *", () => {
|
||||||
backupEmulatorJar().catch((e) =>
|
backupEmulatorJar().catch((e) =>
|
||||||
console.error("[jobs] Backup error:", e),
|
logger.error("Backup error", {
|
||||||
|
module: "jobs",
|
||||||
|
err: e instanceof Error ? e.message : String(e),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
console.log("[jobs] Scheduled: emulator JAR backup (daily 03:00)");
|
logger.info("Scheduled: emulator JAR backup (daily 03:00)", {
|
||||||
|
module: "jobs",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log cleanup — daily at 04:00
|
|
||||||
new Cron("0 4 * * *", () => {
|
new Cron("0 4 * * *", () => {
|
||||||
Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) =>
|
Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) =>
|
||||||
console.error("[jobs] Cleanup error:", e),
|
logger.error("Cleanup error", {
|
||||||
|
module: "jobs",
|
||||||
|
err: e instanceof Error ? e.message : String(e),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
console.log("[jobs] Scheduled: old data cleanup (daily 04:00)");
|
logger.info("Scheduled: old data cleanup (daily 04:00)", { module: "jobs" });
|
||||||
|
|
||||||
// Run once on startup
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
backupEmulatorJar(),
|
backupEmulatorJar(),
|
||||||
cleanupOldLogs(),
|
cleanupOldLogs(),
|
||||||
@@ -93,6 +115,9 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error("[jobs] Fatal:", err);
|
logger.error("Fatal", {
|
||||||
|
module: "jobs",
|
||||||
|
err: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { redactSentryEvent } from "@/lib/sentry-redact";
|
||||||
|
|
||||||
|
const dsn = process.env.SENTRY_DSN;
|
||||||
|
|
||||||
|
if (dsn) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn,
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
release: process.env.APP_VERSION,
|
||||||
|
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
|
||||||
|
enabled: process.env.NODE_ENV === "production",
|
||||||
|
ignoreErrors: ["AbortError", "NEXT_REDIRECT", "NEXT_NOT_FOUND"],
|
||||||
|
beforeSend: redactSentryEvent,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { redactSentryEvent } from "@/lib/sentry-redact";
|
||||||
|
|
||||||
|
const dsn = process.env.SENTRY_DSN;
|
||||||
|
|
||||||
|
if (dsn) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn,
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
release: process.env.APP_VERSION,
|
||||||
|
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
|
||||||
|
enabled: process.env.NODE_ENV === "production",
|
||||||
|
ignoreErrors: [
|
||||||
|
"Network request failed",
|
||||||
|
"AbortError",
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
"NEXT_NOT_FOUND",
|
||||||
|
],
|
||||||
|
beforeSend: redactSentryEvent,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { writeFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { toBadgeGif } from "@/lib/images/badge-gif";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
@@ -52,15 +53,16 @@ export async function uploadBadge(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const gif = await toBadgeGif(buffer);
|
||||||
const baseDir = path.resolve(dir);
|
const baseDir = path.resolve(dir);
|
||||||
const target = path.resolve(baseDir, `${code}.gif`);
|
const target = path.resolve(baseDir, `${code}.gif`);
|
||||||
if (!target.startsWith(baseDir + path.sep)) {
|
if (!target.startsWith(baseDir + path.sep)) {
|
||||||
back("error", "Invalid path");
|
back("error", "Invalid path");
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
await writeFile(target, buffer);
|
await writeFile(target, gif);
|
||||||
} catch {
|
} catch {
|
||||||
back("error", "Could not write the badge file to disk");
|
back("error", "Could not process or write the badge file");
|
||||||
}
|
}
|
||||||
|
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,7 +16,7 @@ export default function GlobalError({
|
|||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error(error);
|
Sentry.captureException(error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ const schema = z.object({
|
|||||||
REDIS_URL: z.string().optional(),
|
REDIS_URL: z.string().optional(),
|
||||||
// Logging level.
|
// Logging level.
|
||||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
|
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
|
||||||
|
// Optional Sentry — no-op when unset.
|
||||||
|
SENTRY_DSN: z.string().url().optional(),
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN: z.string().url().optional(),
|
||||||
|
SENTRY_ORG: z.string().optional(),
|
||||||
|
SENTRY_PROJECT: z.string().optional(),
|
||||||
|
SENTRY_AUTH_TOKEN: z.string().optional(),
|
||||||
|
APP_VERSION: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type Env = z.infer<typeof schema>;
|
type Env = z.infer<typeof schema>;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||||
|
|
||||||
|
if (dsn) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn,
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
|
||||||
|
replaysSessionSampleRate: 0,
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
enabled: process.env.NODE_ENV === "production",
|
||||||
|
integrations: [
|
||||||
|
Sentry.replayIntegration({
|
||||||
|
maskAllText: true,
|
||||||
|
blockAllMedia: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
await import("../sentry.server.config");
|
||||||
|
}
|
||||||
|
if (process.env.NEXT_RUNTIME === "edge") {
|
||||||
|
await import("../sentry.edge.config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onRequestError = Sentry.captureRequestError;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
/** Habbo-style badge icons are small; keep within this box without upscaling. */
|
||||||
|
const MAX_BADGE_EDGE = 64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize an uploaded badge image to GIF bytes for the emulator album folder.
|
||||||
|
* Accepts PNG or GIF input; re-encodes and optionally downsizes oversized assets.
|
||||||
|
*/
|
||||||
|
export async function toBadgeGif(input: Buffer): Promise<Buffer> {
|
||||||
|
return sharp(input)
|
||||||
|
.resize({
|
||||||
|
width: MAX_BADGE_EDGE,
|
||||||
|
height: MAX_BADGE_EDGE,
|
||||||
|
fit: "inside",
|
||||||
|
withoutEnlargement: true,
|
||||||
|
})
|
||||||
|
.gif()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
+26
-72
@@ -1,25 +1,28 @@
|
|||||||
|
import pino from "pino";
|
||||||
|
|
||||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
interface LogEntry {
|
const level: LogLevel =
|
||||||
level: LogLevel;
|
(process.env.LOG_LEVEL as LogLevel | undefined) ??
|
||||||
message: string;
|
|
||||||
timestamp: string;
|
|
||||||
requestId?: string;
|
|
||||||
module?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
|
||||||
debug: 0,
|
|
||||||
info: 1,
|
|
||||||
warn: 2,
|
|
||||||
error: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentLevel: LogLevel =
|
|
||||||
(process.env.LOG_LEVEL as LogLevel) ??
|
|
||||||
(process.env.NODE_ENV === "production" ? "info" : "debug");
|
(process.env.NODE_ENV === "production" ? "info" : "debug");
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
const isTest =
|
||||||
|
process.env.NODE_ENV === "test" || process.env.VITEST === "true";
|
||||||
|
|
||||||
|
const pinoLogger = pino({
|
||||||
|
level,
|
||||||
|
base: { service: "atomcms-next" },
|
||||||
|
...(isProd || isTest
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
transport: {
|
||||||
|
target: "pino-pretty",
|
||||||
|
options: { colorize: true, translateTime: "SYS:standard" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
let requestIdCounter = 0;
|
let requestIdCounter = 0;
|
||||||
|
|
||||||
export function generateRequestId(): string {
|
export function generateRequestId(): string {
|
||||||
@@ -27,67 +30,18 @@ export function generateRequestId(): string {
|
|||||||
return `${Date.now().toString(36)}-${requestIdCounter.toString(36)}`;
|
return `${Date.now().toString(36)}-${requestIdCounter.toString(36)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldLog(level: LogLevel): boolean {
|
/** Compatibility wrapper — existing call sites use (message, meta). */
|
||||||
// eslint-disable-next-line security/detect-object-injection -- LOG_LEVELS keyed by LogLevel union
|
|
||||||
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLog(entry: LogEntry): string {
|
|
||||||
return JSON.stringify(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeLog(entry: LogEntry): void {
|
|
||||||
if (!shouldLog(entry.level)) return;
|
|
||||||
|
|
||||||
const formatted = formatLog(entry);
|
|
||||||
|
|
||||||
switch (entry.level) {
|
|
||||||
case "error":
|
|
||||||
console.error(formatted);
|
|
||||||
break;
|
|
||||||
case "warn":
|
|
||||||
console.warn(formatted);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.log(formatted);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const logger = {
|
export const logger = {
|
||||||
debug(message: string, meta: Record<string, unknown> = {}): void {
|
debug(message: string, meta: Record<string, unknown> = {}): void {
|
||||||
writeLog({
|
pinoLogger.debug(meta, message);
|
||||||
level: "debug",
|
|
||||||
message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
...meta,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
info(message: string, meta: Record<string, unknown> = {}): void {
|
info(message: string, meta: Record<string, unknown> = {}): void {
|
||||||
writeLog({
|
pinoLogger.info(meta, message);
|
||||||
level: "info",
|
|
||||||
message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
...meta,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
warn(message: string, meta: Record<string, unknown> = {}): void {
|
warn(message: string, meta: Record<string, unknown> = {}): void {
|
||||||
writeLog({
|
pinoLogger.warn(meta, message);
|
||||||
level: "warn",
|
|
||||||
message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
...meta,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
error(message: string, meta: Record<string, unknown> = {}): void {
|
error(message: string, meta: Record<string, unknown> = {}): void {
|
||||||
writeLog({
|
pinoLogger.error(meta, message);
|
||||||
level: "error",
|
|
||||||
message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
...meta,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { ErrorEvent, EventHint } from "@sentry/nextjs";
|
||||||
|
|
||||||
|
const SENSITIVE_KEY_RE =
|
||||||
|
/password|secret|token|otp|recovery|authTicket|two_factor|api_key/i;
|
||||||
|
const REDACTED = "[Redacted]";
|
||||||
|
|
||||||
|
function redactObject(input: unknown, depth = 0): unknown {
|
||||||
|
if (depth > 4 || input == null) return input;
|
||||||
|
if (Array.isArray(input)) return input.map((v) => redactObject(v, depth + 1));
|
||||||
|
if (typeof input !== "object") return input;
|
||||||
|
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||||
|
if (SENSITIVE_KEY_RE.test(key)) {
|
||||||
|
out[key] = REDACTED;
|
||||||
|
} else {
|
||||||
|
out[key] = redactObject(value, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scrub cookies/auth headers and sensitive keys before sending to Sentry. */
|
||||||
|
export function redactSentryEvent(
|
||||||
|
event: ErrorEvent,
|
||||||
|
_hint: EventHint,
|
||||||
|
): ErrorEvent | null {
|
||||||
|
if (event.request) {
|
||||||
|
if (event.request.cookies) {
|
||||||
|
event.request.cookies =
|
||||||
|
REDACTED as unknown as typeof event.request.cookies;
|
||||||
|
}
|
||||||
|
if (event.request.headers) {
|
||||||
|
const headers = event.request.headers as Record<string, string>;
|
||||||
|
if (headers.cookie) headers.cookie = REDACTED;
|
||||||
|
if (headers.Cookie) headers.Cookie = REDACTED;
|
||||||
|
if (headers.authorization) headers.authorization = REDACTED;
|
||||||
|
if (headers.Authorization) headers.Authorization = REDACTED;
|
||||||
|
}
|
||||||
|
if (event.request.data) {
|
||||||
|
event.request.data = redactObject(
|
||||||
|
event.request.data,
|
||||||
|
) as typeof event.request.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.extra) event.extra = redactObject(event.extra) as typeof event.extra;
|
||||||
|
if (event.contexts) {
|
||||||
|
event.contexts = redactObject(event.contexts) as typeof event.contexts;
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user