Add observability: health endpoints, structured logging, metrics, Grafana stack
Health endpoints: - /api/health (Journal) and /health (Planner) with DB connectivity check - Docker healthchecks updated to use app health endpoints Structured logging: - Pino with JSON output in production, pretty-print in dev - Request logging middleware in Planner (method, path, status, duration) - Replaced console.log/error with structured logger in email and auth flows Prometheus metrics: - prom-client with default Node.js metrics + custom histograms/gauges - /metrics endpoints on both apps - http_request_duration, planner_active_sessions, brouter_request_duration Monitoring stack: - Prometheus, Loki, Grafana containers in docker-compose - Grafana provisioned with datasources, dashboards, and alert rules - Caddy access logging (JSON to stdout for Loki) - grafana.trails.cool with basic auth via Caddy Dashboards and alerting: - Overview: request rate, error rate, latency p50/p95/p99 - Planner: active sessions, connected clients, BRouter latency - Infrastructure: memory, CPU, event loop lag - Alerts: disk >80%, app down 2min, error rate >5% Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ce964cae96
commit
49aadd04a9
24 changed files with 770 additions and 48 deletions
|
|
@ -7,6 +7,11 @@ vi.mock("nodemailer", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock("./logger.server", () => ({
|
||||
logger: { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
describe("email.server", () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
|
||||
|
|
@ -19,29 +24,27 @@ describe("email.server", () => {
|
|||
delete process.env.SMTP_URL;
|
||||
});
|
||||
|
||||
it("logs to console in dev mode", async () => {
|
||||
it("uses logger in dev mode instead of sending email", async () => {
|
||||
process.env.NODE_ENV = "development";
|
||||
const { sendEmail } = await import("./email.server");
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const { logger } = await import("./logger.server");
|
||||
|
||||
await sendEmail("test@example.com", "Test Subject", "<p>Hello</p>", "Hello");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("test@example.com"),
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "test@example.com" }),
|
||||
expect.any(String),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not call SMTP in dev mode", async () => {
|
||||
process.env.NODE_ENV = "development";
|
||||
const nodemailer = await import("nodemailer");
|
||||
const { sendEmail } = await import("./email.server");
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await sendEmail("test@example.com", "Test", "<p>Hi</p>", "Hi");
|
||||
|
||||
expect(nodemailer.createTransport).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("magicLinkTemplate includes link and expiry note", async () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createTransport, type Transporter } from "nodemailer";
|
||||
import { logger } from "./logger.server";
|
||||
|
||||
const FROM = process.env.SMTP_FROM ?? "trails.cool <noreply@trails.cool>";
|
||||
|
||||
|
|
@ -19,8 +20,8 @@ export async function sendEmail(
|
|||
text: string,
|
||||
): Promise<void> {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.log(`[Email] To: ${to} | Subject: ${subject}`);
|
||||
console.log(`[Email] Text:\n${text}`);
|
||||
logger.info({ to, subject }, "Email sent (dev mode — logged, not delivered)");
|
||||
logger.debug({ text }, "Email text content");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
8
apps/journal/app/lib/logger.server.ts
Normal file
8
apps/journal/app/lib/logger.server.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import pino from "pino";
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL ?? "info",
|
||||
...(process.env.NODE_ENV !== "production"
|
||||
? { transport: { target: "pino-pretty" } }
|
||||
: {}),
|
||||
});
|
||||
13
apps/journal/app/lib/metrics.server.ts
Normal file
13
apps/journal/app/lib/metrics.server.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import client from "prom-client";
|
||||
|
||||
// Collect default Node.js metrics (event loop, heap, GC)
|
||||
client.collectDefaultMetrics();
|
||||
|
||||
export const httpRequestDuration = new client.Histogram({
|
||||
name: "http_request_duration_seconds",
|
||||
help: "Duration of HTTP requests in seconds",
|
||||
labelNames: ["method", "route", "status"] as const,
|
||||
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
|
||||
});
|
||||
|
||||
export const registry = client.register;
|
||||
|
|
@ -2,6 +2,7 @@ import { data } from "react-router";
|
|||
import type { Route } from "./+types/api.auth.register";
|
||||
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish } from "~/lib/auth.server";
|
||||
import { sendWelcome } from "~/lib/email.server";
|
||||
import { logger } from "~/lib/logger.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const body = await request.json();
|
||||
|
|
@ -18,7 +19,7 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
const cookie = await createSession(newUserId, request);
|
||||
// Send welcome email (fire-and-forget — don't block registration on email)
|
||||
sendWelcome(email, username).catch((err) =>
|
||||
console.error("[Email] Failed to send welcome email:", err),
|
||||
logger.error({ err }, "Failed to send welcome email"),
|
||||
);
|
||||
return data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
|
||||
}
|
||||
|
|
|
|||
13
apps/journal/app/routes/api.health.ts
Normal file
13
apps/journal/app/routes/api.health.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { data } from "react-router";
|
||||
import { withDb } from "@trails-cool/db";
|
||||
|
||||
export async function loader() {
|
||||
try {
|
||||
await withDb(async () => {
|
||||
// withDb creates a connection — if it succeeds, DB is reachable
|
||||
});
|
||||
return data({ status: "ok", db: "connected" });
|
||||
} catch {
|
||||
return data({ status: "degraded", db: "unreachable" }, { status: 503 });
|
||||
}
|
||||
}
|
||||
8
apps/journal/app/routes/api.metrics.ts
Normal file
8
apps/journal/app/routes/api.metrics.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { registry } from "~/lib/metrics.server";
|
||||
|
||||
export async function loader() {
|
||||
const metrics = await registry.metrics();
|
||||
return new Response(metrics, {
|
||||
headers: { "Content-Type": registry.contentType },
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue