Openstatus
www.openstatus.dev
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;