trails/apps/planner/server.ts
Ullrich Schäfer f05165c594
fix(sentry): make DSN env-driven so self-hosters can opt out
The Sentry DSNs were hardcoded in journal/server.ts, planner/server.ts,
and journal/app/lib/sentry.client.ts. Self-hosted instances inheriting
the trails.cool flagship DSN would silently ship their errors to our
Sentry account.

Now each init site reads its DSN from env:

- journal/server.ts: SENTRY_DSN (server runtime env)
- planner/server.ts: SENTRY_DSN (server runtime env)
- journal/app/lib/sentry.client.ts: VITE_SENTRY_DSN (build-time bake)

The flagship DSN is kept as the fallback so the production deploy keeps
reporting without an infra change — but self-hosters can:
- set SENTRY_DSN=\"\" / VITE_SENTRY_DSN=\"\" to ship their own builds
  without Sentry, or
- set SENTRY_DISABLED=true to skip init entirely at runtime, or
- set SENTRY_DSN=/VITE_SENTRY_DSN= to their own DSN.

Follow-up: a future PR can remove the hardcoded fallbacks once
infrastructure/docker-compose.yml + the cd-apps.yml workflow are wired
to pass SENTRY_DSN explicitly. That requires SOPS edits + workflow
changes I want isolated from this purely-code change.

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:24:54 +02:00

123 lines
4.4 KiB
TypeScript

import * as Sentry from "@sentry/node";
import { nodeSentryConfig, drop404s } from "@trails-cool/sentry-config";
import { logger } from "./app/lib/logger.server.ts";
import { httpRequestDuration } from "./app/lib/metrics.server.ts";
import { createRequestListener } from "@react-router/node";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { createReadStream, statSync } from "node:fs";
import { join, extname, resolve } from "node:path";
import { setupYjsWebSocket } from "./app/lib/yjs-server.ts";
import { createBoss, startWorker } from "@trails-cool/jobs";
import { expireSessionsJob } from "./app/jobs/expire-sessions.ts";
import postgres from "postgres";
// See apps/journal/server.ts for the SENTRY_DSN / SENTRY_DISABLED contract.
const FLAGSHIP_PLANNER_SENTRY_DSN =
"https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208";
const sentryDsn = process.env.SENTRY_DSN ?? FLAGSHIP_PLANNER_SENTRY_DSN;
if (process.env.SENTRY_DISABLED !== "true" && sentryDsn !== "") {
Sentry.init({
dsn: sentryDsn,
...nodeSentryConfig("planner server"),
beforeSend: drop404s,
});
}
const port = Number(process.env.PORT ?? 3001);
const CLIENT_DIR = resolve(import.meta.dirname, "build", "client");
const MIME: Record<string, string> = {
".js": "application/javascript",
".css": "text/css",
".html": "text/html",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
};
function serveStatic(req: IncomingMessage, res: ServerResponse): boolean {
if (req.method !== "GET" && req.method !== "HEAD") return false;
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
const filePath = resolve(join(CLIENT_DIR, url.pathname));
if (!filePath.startsWith(CLIENT_DIR)) return false;
try {
if (!statSync(filePath).isFile()) return false;
} catch {
return false;
}
res.setHeader("Content-Type", MIME[extname(filePath)] ?? "application/octet-stream");
if (url.pathname.startsWith("/assets/")) {
res.setHeader("Cache-Control", "public, immutable, max-age=31536000");
}
createReadStream(filePath).pipe(res);
return true;
}
const listener = createRequestListener({
build: () => import("./build/server/index.js") as never,
});
async function handleMetrics(_req: IncomingMessage, res: ServerResponse): Promise<void> {
const { registry } = await import("./app/lib/metrics.server.ts");
const metrics = await registry.metrics();
res.writeHead(200, { "Content-Type": registry.contentType });
res.end(metrics);
}
const version = process.env.SENTRY_RELEASE ?? "dev";
async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise<void> {
const client = postgres(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails", { max: 1 });
try {
await client`SELECT 1`;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", version, db: "connected" }));
} catch {
res.writeHead(503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "degraded", version, db: "unreachable" }));
} finally {
await client.end();
}
}
const server = createServer((req, res) => {
const url = req.url ?? "/";
const start = Date.now();
// Log and track request on finish (skip static assets and health/metrics)
if (!url.startsWith("/assets/") && url !== "/health" && url !== "/metrics") {
res.on("finish", () => {
const duration = Date.now() - start;
logger.info({ method: req.method, path: url, status: res.statusCode, duration }, "request");
httpRequestDuration.observe(
{ method: req.method ?? "GET", route: url.split("?")[0]!, status: String(res.statusCode) },
duration / 1000,
);
});
}
if (url === "/health") { handleHealth(req, res); return; }
if (url === "/metrics") { handleMetrics(req, res); return; }
if (!serveStatic(req, res)) {
listener(req, res);
}
});
setupYjsWebSocket(server);
server.listen(port, async () => {
logger.info({ port }, "Planner server listening");
logger.info({ port, path: "/sync/:sessionId" }, "Yjs WebSocket available");
const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails");
await startWorker(boss, [expireSessionsJob]);
logger.info("Background job worker started");
});