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
@@ -4,6 +4,7 @@ import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { toBadgeGif } from "@/lib/images/badge-gif";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
@@ -52,15 +53,16 @@ export async function uploadBadge(formData: FormData): Promise<void> {
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const gif = await toBadgeGif(buffer);
|
||||
const baseDir = path.resolve(dir);
|
||||
const target = path.resolve(baseDir, `${code}.gif`);
|
||||
if (!target.startsWith(baseDir + path.sep)) {
|
||||
back("error", "Invalid path");
|
||||
}
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(target, buffer);
|
||||
await writeFile(target, gif);
|
||||
} catch {
|
||||
back("error", "Could not write the badge file to disk");
|
||||
back("error", "Could not process or write the badge file");
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
@@ -15,7 +16,7 @@ export default function GlobalError({
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -73,6 +73,13 @@ const schema = z.object({
|
||||
REDIS_URL: z.string().optional(),
|
||||
// Logging level.
|
||||
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>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
interface LogEntry {
|
||||
level: LogLevel;
|
||||
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) ??
|
||||
const level: LogLevel =
|
||||
(process.env.LOG_LEVEL as LogLevel | undefined) ??
|
||||
(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;
|
||||
|
||||
export function generateRequestId(): string {
|
||||
@@ -27,67 +30,18 @@ export function generateRequestId(): string {
|
||||
return `${Date.now().toString(36)}-${requestIdCounter.toString(36)}`;
|
||||
}
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compatibility wrapper — existing call sites use (message, meta). */
|
||||
export const logger = {
|
||||
debug(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({
|
||||
level: "debug",
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...meta,
|
||||
});
|
||||
pinoLogger.debug(meta, message);
|
||||
},
|
||||
|
||||
info(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({
|
||||
level: "info",
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...meta,
|
||||
});
|
||||
pinoLogger.info(meta, message);
|
||||
},
|
||||
|
||||
warn(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({
|
||||
level: "warn",
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...meta,
|
||||
});
|
||||
pinoLogger.warn(meta, message);
|
||||
},
|
||||
|
||||
error(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({
|
||||
level: "error",
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...meta,
|
||||
});
|
||||
pinoLogger.error(meta, message);
|
||||
},
|
||||
};
|
||||
@@ -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