Self-hosted instances no longer inherit the trails.cool flagship Sentry DSNs. Code paths now read DSN strictly from env — unset = no Sentry init, no events sent. Flagship continues to report unchanged: cd-apps.yml and cd-staging.yml inject the public DSNs as workflow env vars, which feed into: - runtime via \`infrastructure/app.env\` / \`staging.env\` (\`SENTRY_DSN_JOURNAL\` / \`SENTRY_DSN_PLANNER\` → compose env per service) - build time via docker build-arg \`VITE_SENTRY_DSN\` (journal only; planner has no client Sentry init). Sentry DSNs are public-by-design (transmitted unencrypted from the client JS bundle), so embedding them as plaintext workflow env vars is no worse than the runtime exposure. Forks should replace these with their own DSNs or remove the workflow env lines to ship Sentry-free. Files changed: - apps/journal/server.ts: \`process.env.SENTRY_DSN\` only, no fallback - apps/planner/server.ts: same - apps/journal/app/lib/sentry.client.ts: \`import.meta.env.VITE_SENTRY_DSN\` only; falsy = skip init - apps/journal/Dockerfile: new \`ARG VITE_SENTRY_DSN\` baked into build - infrastructure/docker-compose.yml: pass \`SENTRY_DSN\` per service - infrastructure/docker-compose.staging.yml: same - .github/workflows/cd-apps.yml: workflow env + build-arg + app.env echo - .github/workflows/cd-staging.yml: same Full repo: pnpm typecheck, pnpm lint, pnpm test, journal build all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
5.1 KiB
TypeScript
142 lines
5.1 KiB
TypeScript
import * as Sentry from "@sentry/node";
|
|
import { nodeSentryConfig, drop404s } from "@trails-cool/sentry-config";
|
|
import { logger, requestContext } from "./app/lib/logger.server.ts";
|
|
import { randomUUID } from "node:crypto";
|
|
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 { getDatabaseUrl } from "@trails-cool/db";
|
|
import postgres, { type Sql } from "postgres";
|
|
|
|
// See apps/journal/server.ts for the SENTRY_DSN / SENTRY_DISABLED contract.
|
|
const sentryDsn = process.env.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";
|
|
|
|
// Module-level singleton dedicated to /health (mirrors PR #430 for the
|
|
// journal). Previously a fresh client + connection was opened on every
|
|
// probe; under monitoring cadence that's a fresh handshake every few
|
|
// seconds. Kept separate from any future main-app pool so a starvation
|
|
// event on the app pool doesn't fail the liveness probe.
|
|
let healthClient: Sql | null = null;
|
|
function getHealthClient(): Sql {
|
|
if (!healthClient) {
|
|
healthClient = postgres(getDatabaseUrl(), { max: 2, idle_timeout: 30 });
|
|
}
|
|
return healthClient;
|
|
}
|
|
|
|
async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
try {
|
|
await getHealthClient()`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" }));
|
|
}
|
|
}
|
|
|
|
const server = createServer((req, res) => {
|
|
const url = req.url ?? "/";
|
|
const start = Date.now();
|
|
|
|
// Honor an inbound X-Request-Id (e.g. from Caddy or a probe) so the
|
|
// request ID propagates across the proxy hop; otherwise mint a fresh
|
|
// UUID. Echo on the response so clients can correlate.
|
|
const inbound = req.headers["x-request-id"];
|
|
const requestId =
|
|
(Array.isArray(inbound) ? inbound[0] : inbound) || randomUUID();
|
|
res.setHeader("X-Request-Id", requestId);
|
|
|
|
requestContext.run({ requestId }, () => {
|
|
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(getDatabaseUrl());
|
|
await startWorker(boss, [expireSessionsJob]);
|
|
logger.info("Background job worker started");
|
|
});
|