Openstatus www.openstatus.dev
at main 213 lines 5.3 kB view raw
1import { AsyncLocalStorage } from "node:async_hooks"; 2 3import { sentry } from "@hono/sentry"; 4import { 5 configure, 6 // configureSync, 7 getConsoleSink, 8 getLogger, 9 jsonLinesFormatter, 10 withContext, 11} from "@logtape/logtape"; 12import { getOpenTelemetrySink } from "@logtape/otel"; 13import { Hono } from "hono"; 14import { showRoutes } from "hono/dev"; 15 16import { resourceFromAttributes } from "@opentelemetry/resources"; 17import { ATTR_DEPLOYMENT_ENVIRONMENT_NAME } from "@opentelemetry/semantic-conventions/incubating"; 18import { prettyJSON } from "hono/pretty-json"; 19import { requestId } from "hono/request-id"; 20import { env } from "./env"; 21import { handleError } from "./libs/errors"; 22import { publicRoute } from "./routes/public"; 23import { mountRpcRoutes } from "./routes/rpc"; 24import { api } from "./routes/v1"; 25 26type Env = { 27 Variables: { 28 event: Record<string, unknown>; 29 }; 30}; 31 32// Export app before any top-level await to avoid "Cannot access before initialization" errors in tests 33export const app = new Hono<Env>({ 34 strict: false, 35}); 36 37const logger = getLogger("api-server"); 38const otelLogger = getLogger("api-server-otel"); 39 40/** 41 * Configure logging asynchronously without blocking module initialization. 42 * This allows tests to import `app` immediately. 43 */ 44 45const defaultLogger = getOpenTelemetrySink({ 46 serviceName: "openstatus-server", 47 otlpExporterConfig: { 48 url: "https://eu-central-1.aws.edge.axiom.co/v1/logs", 49 headers: { 50 Authorization: `Bearer ${env.AXIOM_TOKEN}`, 51 "X-Axiom-Dataset": env.AXIOM_DATASET, 52 }, 53 }, 54 additionalResource: resourceFromAttributes({ 55 [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: env.NODE_ENV, 56 }), 57}); 58 59await configure({ 60 sinks: { 61 console: getConsoleSink({ formatter: jsonLinesFormatter }), 62 otel: defaultLogger, 63 }, 64 loggers: [ 65 { 66 category: "api-server", 67 lowestLevel: "error", 68 sinks: ["console"], 69 }, 70 { 71 category: "api-server-otel", 72 lowestLevel: "info", 73 sinks: ["otel"], 74 }, 75 ], 76 contextLocalStorage: new AsyncLocalStorage(), 77}); 78 79/* biome-ignore lint/suspicious/noExplicitAny: <explanation> */ 80function shouldSample(event: Record<string, any>): boolean { 81 // Always keep errors 82 if (event.status_code >= 500) return true; 83 if (event.error) return true; 84 85 // Always keep slow requests (above p99) 86 if (event.duration_ms > 2000) return true; 87 88 // Random sample the rest at 20% 89 return Math.random() < 0.2; 90} 91 92/** 93 * Middleware 94 */ 95app.use("*", sentry({ dsn: process.env.SENTRY_DSN })); 96app.use("*", requestId()); 97app.use("*", prettyJSON()); 98 99app.use("*", async (c, next) => { 100 const reqId = c.get("requestId"); 101 const startTime = Date.now(); 102 103 await withContext( 104 { 105 request_id: reqId, 106 method: c.req.method, 107 url: c.req.url, 108 user_agent: c.req.header("User-Agent"), 109 }, 110 async () => { 111 // Initialize wide event - one canonical log line per request 112 const event: Record<string, unknown> = { 113 timestamp: new Date().toISOString(), 114 request_id: reqId, 115 // Request context 116 method: c.req.method, 117 path: c.req.path, 118 url: c.req.url, 119 // Client context 120 user_agent: c.req.header("User-Agent"), 121 // Request metadata 122 content_type: c.req.header("Content-Type"), 123 // Environment characteristics 124 service: "api-server", 125 environment: env.NODE_ENV, 126 region: env.FLY_REGION, 127 }; 128 c.set("event", event); 129 130 await next(); 131 132 // Performance 133 const duration = Date.now() - startTime; 134 event.duration_ms = duration; 135 136 // Response context 137 event.status_code = c.res.status; 138 139 // Outcome 140 if (c.error) { 141 event.outcome = "error"; 142 event.error = { 143 type: c.error.name, 144 message: c.error.message, 145 stack: c.error.stack, 146 }; 147 } else { 148 event.outcome = c.res.status < 400 ? "success" : "failure"; 149 } 150 151 // Emit single canonical log line (sampled for otel, always for console in dev) 152 if (shouldSample(event)) { 153 otelLogger.info("request", { ...event }); 154 } 155 156 // Console logging only for errors in production 157 if (env.NODE_ENV !== "production" || c.res.status >= 500) { 158 logger.info("request", { 159 request_id: requestId, 160 method: c.req.method, 161 path: c.req.path, 162 status_code: c.res.status, 163 duration_ms: duration, 164 outcome: event.outcome, 165 }); 166 } 167 }, 168 ); 169}); 170 171app.onError(handleError); 172 173/** 174 * ConnectRPC Routes API v2 ftw 175 */ 176mountRpcRoutes(app); 177 178/** 179 * Public Routes 180 */ 181app.route("/public", publicRoute); 182 183/** 184 * Ping Pong 185 */ 186app.get("/ping", (c) => { 187 return c.json( 188 { ping: "pong", region: env.FLY_REGION, requestId: c.get("requestId") }, 189 200, 190 ); 191}); 192 193/** 194 * API Routes v1 195 */ 196app.route("/v1", api); 197 198/** 199 * TODO: move to `workflows` app 200 * This route is used by our checker to update the status of the monitors, 201 * create incidents, and send notifications. 202 */ 203 204const isDev = process.env.NODE_ENV === "development"; 205const port = 3000; 206 207if (isDev) showRoutes(app, { verbose: true, colorize: true }); 208 209logger.info("Starting server", { port, environment: env.NODE_ENV }); 210 211const server = { port, fetch: app.fetch }; 212 213export default server;