Skip to content

Commit 1619405

Browse files
committed
feat(api): add OpenTelemetry observability
1 parent 600654a commit 1619405

36 files changed

Lines changed: 2653 additions & 152 deletions

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,8 @@ MODERATION_SCHEDULE_INTERVAL_MS=3600000
6161
MODERATION_SCAN_BATCH_SIZE=200
6262
MODERATION_SCAN_THROTTLE_MS=500
6363
MODERATION_SCAN_MAX_BATCHES_PER_RUN=100
64+
65+
# OpenTelemetry tracing is disabled by default for local development.
66+
CNODE_OTEL_ENABLED=0
67+
CNODE_OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318/v1/traces
68+
CNODE_OTEL_TRACE_SAMPLE_RATIO=0.1

apps/api/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,4 @@ COPY --from=build /app/packages/db /app/packages/db
4242
COPY --from=build /app/packages/shared /app/packages/shared
4343
COPY --from=build /app/scripts /app/scripts
4444
EXPOSE 3001
45-
CMD ["pnpm", "--filter", "@cnode/api", "exec", "tsx", "src/index.ts"]
45+
CMD ["pnpm", "--filter", "@cnode/api", "exec", "tsx", "src/bootstrap.ts", "api"]

apps/api/package.json

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
"private": true,
55
"type": "module",
66
"scripts": {
7-
"dev": "tsx watch src/index.ts",
7+
"dev": "tsx watch src/bootstrap.ts api",
88
"build": "tsc",
9-
"start": "node dist/index.js",
9+
"start": "tsx src/bootstrap.ts api",
1010
"test": "vp test run",
11-
"worker:moderation": "tsx src/worker/moderation-scan.ts",
11+
"worker:moderation": "tsx src/bootstrap.ts moderation-worker",
1212
"typecheck": "tsc --noEmit",
1313
"gen:openapi": "tsx scripts/gen-openapi.ts"
1414
},
@@ -18,6 +18,16 @@
1818
"@hono/node-server": "^1.13.0",
1919
"@hono/zod-openapi": "^1.5.1",
2020
"@hono/zod-validator": "^0.4.0",
21+
"@opentelemetry/api": "^1.9.1",
22+
"@opentelemetry/core": "^2.10.0",
23+
"@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
24+
"@opentelemetry/instrumentation-http": "^0.221.0",
25+
"@opentelemetry/instrumentation-pg": "^0.73.0",
26+
"@opentelemetry/instrumentation-undici": "^0.31.0",
27+
"@opentelemetry/resources": "^2.10.0",
28+
"@opentelemetry/sdk-node": "^0.221.0",
29+
"@opentelemetry/sdk-trace-base": "^2.10.0",
30+
"@opentelemetry/semantic-conventions": "^1.43.0",
2131
"@react-email/render": "2.1.0",
2232
"ali-oss": "^6.21.0",
2333
"bcryptjs": "catalog:",

apps/api/src/app.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import packageJson from "../package.json";
55
import { errorHandler } from "./middleware/error";
66
import { authMiddleware, type AuthVars } from "./middleware/auth";
77
import { ipBanMiddleware } from "./middleware/ip-ban";
8+
import { telemetryMiddleware, type TelemetryVariables } from "./middleware/telemetry";
89
import { apiRoutes } from "./routes/index";
910

1011
const app = new Hono<{
11-
Variables: AuthVars;
12+
Variables: AuthVars & TelemetryVariables;
1213
}>();
1314

1415
function allowedCorsOrigin(origin: string | undefined) {
@@ -24,6 +25,7 @@ function allowedCorsOrigin(origin: string | undefined) {
2425
return origin && allowed.has(origin) ? origin : webBaseUrl;
2526
}
2627

28+
app.use("*", telemetryMiddleware());
2729
app.use("*", logger());
2830
app.use(
2931
"*",

apps/api/src/bootstrap.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { pathToFileURL } from "node:url";
2+
import { configureProxy } from "./load-env";
3+
import {
4+
initializeTelemetry,
5+
type TelemetryRuntime,
6+
} from "./telemetry/index";
7+
import type { TelemetryRole } from "./telemetry/config";
8+
9+
interface BootstrapDependencies {
10+
initialize?: (role: TelemetryRole) => Promise<TelemetryRuntime>;
11+
configureRuntimeProxy?: () => Promise<void>;
12+
importTarget?: (role: TelemetryRole) => Promise<unknown>;
13+
installSignalHandlers?: boolean;
14+
}
15+
16+
function parseRole(value: string | undefined): TelemetryRole {
17+
if (value === "api" || value === "moderation-worker") return value;
18+
throw new Error("bootstrap role must be api or moderation-worker");
19+
}
20+
21+
async function importTarget(role: TelemetryRole) {
22+
return role === "api" ? import("./index") : import("./worker/moderation-scan");
23+
}
24+
25+
function registerShutdown(runtime: TelemetryRuntime) {
26+
let stopping = false;
27+
const stop = async () => {
28+
if (stopping) return;
29+
stopping = true;
30+
await runtime.shutdown();
31+
process.exit(0);
32+
};
33+
process.once("SIGTERM", stop);
34+
process.once("SIGINT", stop);
35+
}
36+
37+
export async function runBootstrap(role: TelemetryRole, dependencies: BootstrapDependencies = {}) {
38+
const initialize = dependencies.initialize ?? initializeTelemetry;
39+
const runtime = await initialize(role);
40+
41+
try {
42+
await (dependencies.configureRuntimeProxy ?? configureProxy)();
43+
if (dependencies.installSignalHandlers !== false) registerShutdown(runtime);
44+
await (dependencies.importTarget ?? importTarget)(role);
45+
} catch (error) {
46+
await runtime.shutdown();
47+
throw error;
48+
}
49+
}
50+
51+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
52+
if (isMain) {
53+
runBootstrap(parseRole(process.argv[2])).catch((error) => {
54+
console.error("[bootstrap] application startup failed", error);
55+
process.exit(1);
56+
});
57+
}

apps/api/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import "./load-env";
21
import { serve } from "@hono/node-server";
32
import app from "./app";
43

apps/api/src/load-env.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { existsSync, readFileSync } from "node:fs";
22
import { isAbsolute, resolve } from "node:path";
3-
import { setGlobalDispatcher, ProxyAgent } from "undici";
43

54
function findWorkspaceRoot(cwd: string) {
65
let current = resolve(cwd);
@@ -69,8 +68,10 @@ function loadRootEnv() {
6968

7069
loadRootEnv();
7170

72-
const proxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
73-
if (proxy) {
71+
export async function configureProxy() {
72+
const proxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
73+
if (!proxy) return;
74+
const { setGlobalDispatcher, ProxyAgent } = await import("undici");
7475
setGlobalDispatcher(new ProxyAgent(proxy));
75-
console.log("[proxy] set global dispatcher:", proxy);
76+
console.log("[proxy] configured");
7677
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { randomUUID } from "node:crypto";
2+
import { SpanKind, SpanStatusCode, trace, type Tracer } from "@opentelemetry/api";
3+
import { createMiddleware } from "hono/factory";
4+
5+
export interface TelemetryVariables {
6+
requestId: string;
7+
}
8+
9+
const allowedErrorTypes = new Set(["Error", "HTTPException"]);
10+
11+
function routeName(method: string, routePath: string) {
12+
return `${method.toUpperCase()} ${routePath || "unmatched"}`;
13+
}
14+
15+
export function telemetryMiddleware(tracer: Tracer = trace.getTracer("cnode-api-hono")) {
16+
return createMiddleware<{ Variables: TelemetryVariables }>(async (c, next) => {
17+
const requestId = randomUUID();
18+
c.set("requestId", requestId);
19+
c.header("X-Request-ID", requestId);
20+
21+
return tracer.startActiveSpan(
22+
"HTTP request",
23+
{
24+
kind: SpanKind.INTERNAL,
25+
attributes: {
26+
"cnode.request.id": requestId,
27+
"http.request.method": c.req.method,
28+
},
29+
},
30+
async (span) => {
31+
try {
32+
await next();
33+
const status = c.res.status;
34+
span.setAttribute("http.response.status_code", status);
35+
if (status >= 500) span.setStatus({ code: SpanStatusCode.ERROR });
36+
} catch (error) {
37+
span.setStatus({ code: SpanStatusCode.ERROR });
38+
const errorType = error instanceof Error ? error.name : "unknown";
39+
if (allowedErrorTypes.has(errorType)) span.setAttribute("error.type", errorType);
40+
throw error;
41+
} finally {
42+
const route = c.req.routePath || "unmatched";
43+
span.updateName(routeName(c.req.method, route));
44+
span.setAttribute("http.route", route);
45+
span.end();
46+
}
47+
},
48+
);
49+
});
50+
}

apps/api/src/telemetry/config.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import packageJson from "../../package.json";
2+
3+
export type TelemetryRole = "api" | "moderation-worker";
4+
5+
export interface TelemetryConfig {
6+
enabled: boolean;
7+
endpoint?: string;
8+
sampleRatio: number;
9+
serviceName: "cnode-api" | "cnode-moderation-worker";
10+
serviceVersion: string;
11+
commitRevision: string;
12+
deploymentEnvironment: string;
13+
}
14+
15+
export class TelemetryConfigError extends Error {
16+
constructor(variable: string, expectation: string) {
17+
super(`${variable} ${expectation}`);
18+
this.name = "TelemetryConfigError";
19+
}
20+
}
21+
22+
function parseEnabled(value: string | undefined) {
23+
if (value === undefined || value === "" || value === "0" || value === "false") return false;
24+
if (value === "1" || value === "true") return true;
25+
throw new TelemetryConfigError("CNODE_OTEL_ENABLED", "must be a boolean");
26+
}
27+
28+
function parseSampleRatio(value: string | undefined) {
29+
if (value === undefined || value === "") return 0.1;
30+
const ratio = Number(value);
31+
if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) {
32+
throw new TelemetryConfigError(
33+
"CNODE_OTEL_TRACE_SAMPLE_RATIO",
34+
"must be a finite number between 0 and 1",
35+
);
36+
}
37+
return ratio;
38+
}
39+
40+
function parseEndpoint(value: string | undefined) {
41+
if (!value) {
42+
throw new TelemetryConfigError("CNODE_OTEL_EXPORTER_OTLP_ENDPOINT", "is required");
43+
}
44+
45+
try {
46+
const endpoint = new URL(value);
47+
if (endpoint.protocol !== "http:" && endpoint.protocol !== "https:") throw new Error();
48+
if (endpoint.username || endpoint.password) throw new Error();
49+
return endpoint.toString();
50+
} catch {
51+
throw new TelemetryConfigError(
52+
"CNODE_OTEL_EXPORTER_OTLP_ENDPOINT",
53+
"must be an HTTP URL without credentials",
54+
);
55+
}
56+
}
57+
58+
export function parseTelemetryConfig(
59+
role: TelemetryRole,
60+
env: NodeJS.ProcessEnv = process.env,
61+
): TelemetryConfig {
62+
const enabled = parseEnabled(env.CNODE_OTEL_ENABLED);
63+
return {
64+
enabled,
65+
endpoint: enabled ? parseEndpoint(env.CNODE_OTEL_EXPORTER_OTLP_ENDPOINT) : undefined,
66+
sampleRatio: enabled ? parseSampleRatio(env.CNODE_OTEL_TRACE_SAMPLE_RATIO) : 0,
67+
serviceName: role === "api" ? "cnode-api" : "cnode-moderation-worker",
68+
serviceVersion: packageJson.version || "unknown",
69+
commitRevision: env.CNODE_GIT_SHA || env.GIT_SHA || env.COMMIT_SHA || "unknown",
70+
deploymentEnvironment: env.CNODE_ENV || env.NODE_ENV || "unknown",
71+
};
72+
}

0 commit comments

Comments
 (0)