Openstatus www.openstatus.dev
at 4c0f4c00a38753a5d0dfd7e7b7b7706dec6f1503 199 lines 4.8 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 { api } from "./routes/v1"; 24 25type Env = { 26 Variables: { 27 event: Record<string, unknown>; 28 }; 29}; 30 31/* biome-ignore lint/suspicious/noExplicitAny: <explanation> */ 32function shouldSample(event: Record<string, any>): boolean { 33 // Always keep errors 34 if (event.status_code >= 500) return true; 35 if (event.error) return true; 36 37 // Always keep slow requests (above p99) 38 if (event.duration_ms > 2000) return true; 39 40 // Random sample the rest at 20% 41 return Math.random() < 0.2; 42} 43 44const defaultLogger = getOpenTelemetrySink({ 45 serviceName: "openstatus-server", 46 otlpExporterConfig: { 47 url: "https://eu-central-1.aws.edge.axiom.co/v1/logs", 48 headers: { 49 Authorization: `Bearer ${env.AXIOM_TOKEN}`, 50 "X-Axiom-Dataset": env.AXIOM_DATASET, 51 }, 52 }, 53 additionalResource: resourceFromAttributes({ 54 [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: env.NODE_ENV, 55 }), 56}); 57 58await configure({ 59 sinks: { 60 console: getConsoleSink({ formatter: jsonLinesFormatter }), 61 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 79const logger = getLogger("api-server"); 80 81const otelLogger = getLogger("api-server-otel"); 82 83export const app = new Hono<Env>({ 84 strict: false, 85}); 86 87/** 88 * Middleware 89 */ 90app.use("*", sentry({ dsn: process.env.SENTRY_DSN })); 91app.use("*", requestId()); 92app.use("*", prettyJSON()); 93 94app.use("*", async (c, next) => { 95 const requestId = c.get("requestId"); 96 const startTime = Date.now(); 97 98 await withContext( 99 { 100 request_id: requestId, 101 method: c.req.method, 102 url: c.req.url, 103 user_agent: c.req.header("User-Agent"), 104 }, 105 async () => { 106 // Initialize wide event - one canonical log line per request 107 const event: Record<string, unknown> = { 108 timestamp: new Date().toISOString(), 109 request_id: requestId, 110 // Request context 111 method: c.req.method, 112 path: c.req.path, 113 url: c.req.url, 114 // Client context 115 user_agent: c.req.header("User-Agent"), 116 // Request metadata 117 content_type: c.req.header("Content-Type"), 118 }; 119 c.set("event", event); 120 121 await next(); 122 123 // Performance 124 const duration = Date.now() - startTime; 125 event.duration_ms = duration; 126 127 // Response context 128 event.status_code = c.res.status; 129 130 // Outcome 131 if (c.error) { 132 event.outcome = "error"; 133 event.error = { 134 type: c.error.name, 135 message: c.error.message, 136 stack: c.error.stack, 137 }; 138 } else { 139 event.outcome = c.res.status < 400 ? "success" : "failure"; 140 } 141 142 // Emit single canonical log line (sampled for otel, always for console in dev) 143 if (shouldSample(event)) { 144 otelLogger.info("request", { ...event }); 145 } 146 147 // Console logging only for errors in production 148 if (env.NODE_ENV !== "production" || c.res.status >= 500) { 149 logger.info("request", { 150 request_id: requestId, 151 method: c.req.method, 152 path: c.req.path, 153 status_code: c.res.status, 154 duration_ms: duration, 155 outcome: event.outcome, 156 }); 157 } 158 }, 159 ); 160}); 161 162app.onError(handleError); 163 164/** 165 * Public Routes 166 */ 167app.route("/public", publicRoute); 168 169/** 170 * Ping Pong 171 */ 172app.get("/ping", (c) => { 173 return c.json( 174 { ping: "pong", region: env.FLY_REGION, requestId: c.get("requestId") }, 175 200, 176 ); 177}); 178 179/** 180 * API Routes v1 181 */ 182app.route("/v1", api); 183 184/** 185 * TODO: move to `workflows` app 186 * This route is used by our checker to update the status of the monitors, 187 * create incidents, and send notifications. 188 */ 189 190const isDev = process.env.NODE_ENV === "development"; 191const port = 3000; 192 193if (isDev) showRoutes(app, { verbose: true, colorize: true }); 194 195console.log(`Starting server on port ${port}`); 196 197const server = { port, fetch: app.fetch }; 198 199export default server;