Skip to content

Commit cffc8b4

Browse files
committed
Instrument application with opentelemetry (#3)
* Add otel telemetry to next app * Simplify otel implementation * remove plan
1 parent 067fbb1 commit cffc8b4

9 files changed

Lines changed: 475 additions & 19 deletions

File tree

app/api/team/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ export async function GET() {
1212
});
1313
}
1414

15+
if (await isFaultActive("api-team-db-read-skipped")) {
16+
return Response.json(null, {
17+
headers: { "x-fault-injected": "api-team-db-read-skipped" },
18+
});
19+
}
20+
1521
const team = await getTeamForUser();
1622
return Response.json(team);
1723
}

instrumentation.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { registerOTel } from "@vercel/otel";
2+
3+
function backendOtelEnabled() {
4+
return Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
5+
}
6+
7+
export function register() {
8+
if (!backendOtelEnabled()) {
9+
return;
10+
}
11+
12+
registerOTel({
13+
serviceName: process.env.OTEL_SERVICE_NAME || "playwright-tutorial-next",
14+
attributes: {
15+
"service.namespace": "playwright-tutorial",
16+
"endform.telemetry.source": "next-app",
17+
},
18+
});
19+
}

lib/auth/middleware.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@ import type { TeamDataWithMembers, User } from "@/lib/db/schema";
66
export type ActionState = {
77
error?: string;
88
success?: string;
9-
[key: string]: any; // This allows for additional properties
9+
[key: string]: string | number | readonly string[] | undefined;
1010
};
1111

12-
type ValidatedActionFunction<S extends z.ZodType<any, any>, T> = (
12+
type ValidatedActionFunction<S extends z.ZodType, T> = (
1313
data: z.infer<S>,
1414
formData: FormData,
1515
) => Promise<T>;
1616

17-
export function validatedAction<S extends z.ZodType<any, any>, T>(
17+
export function validatedAction<S extends z.ZodType, T>(
1818
schema: S,
1919
action: ValidatedActionFunction<S, T>,
2020
) {
@@ -28,13 +28,13 @@ export function validatedAction<S extends z.ZodType<any, any>, T>(
2828
};
2929
}
3030

31-
type ValidatedActionWithUserFunction<S extends z.ZodType<any, any>, T> = (
31+
type ValidatedActionWithUserFunction<S extends z.ZodType, T> = (
3232
data: z.infer<S>,
3333
formData: FormData,
3434
user: User,
3535
) => Promise<T>;
3636

37-
export function validatedActionWithUser<S extends z.ZodType<any, any>, T>(
37+
export function validatedActionWithUser<S extends z.ZodType, T>(
3838
schema: S,
3939
action: ValidatedActionWithUserFunction<S, T>,
4040
) {

lib/db/drizzle.ts

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type AsyncRemoteCallback,
77
drizzle as drizzleProxy,
88
} from "drizzle-orm/sqlite-proxy";
9+
import { withSpan } from "../telemetry";
910
import { resolveRuntimeDatabaseConfig } from "./config";
1011
import * as schema from "./schema";
1112

@@ -14,6 +15,11 @@ dotenv.config();
1415
type QueryMethod = Parameters<AsyncRemoteCallback>[2];
1516
type BatchQuery = Parameters<AsyncBatchRemoteCallback>[0][number];
1617
type ProxyQueryResult = Awaited<ReturnType<AsyncRemoteCallback>>;
18+
type LibsqlClient = ReturnType<typeof createClient>;
19+
type InstrumentableLibsqlClient = {
20+
execute: (...args: unknown[]) => Promise<unknown>;
21+
batch: (...args: unknown[]) => Promise<unknown>;
22+
};
1723

1824
const databaseConfig = resolveRuntimeDatabaseConfig();
1925

@@ -25,6 +31,16 @@ async function executeProxyQuery(
2531
sql: string,
2632
params: unknown[],
2733
method: QueryMethod,
34+
): Promise<ProxyQueryResult> {
35+
return withDbSpan(sql, method, () =>
36+
executeProxyQueryUntraced(sql, params, method),
37+
);
38+
}
39+
40+
async function executeProxyQueryUntraced(
41+
sql: string,
42+
params: unknown[],
43+
method: QueryMethod,
2844
): Promise<ProxyQueryResult> {
2945
const proxyUrl =
3046
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
@@ -51,6 +67,15 @@ async function executeProxyQuery(
5167

5268
async function executeProxyBatch(
5369
queries: BatchQuery[],
70+
): Promise<ProxyQueryResult[]> {
71+
return withDbBatchSpan(
72+
queries.map((query) => query.sql),
73+
() => executeProxyBatchUntraced(queries),
74+
);
75+
}
76+
77+
async function executeProxyBatchUntraced(
78+
queries: BatchQuery[],
5479
): Promise<ProxyQueryResult[]> {
5580
const proxyUrl =
5681
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
@@ -84,6 +109,149 @@ function ensureTrailingSlash(url: string) {
84109
return url.endsWith("/") ? url : `${url}/`;
85110
}
86111

112+
function instrumentLibsqlClient(client: LibsqlClient): LibsqlClient {
113+
const instrumented = client as unknown as InstrumentableLibsqlClient;
114+
const execute = instrumented.execute.bind(client);
115+
const batch = instrumented.batch.bind(client);
116+
117+
instrumented.execute = (...args: unknown[]) => {
118+
const sql = sqlFromStatement(args[0]);
119+
return withDbSpan(sql, "execute", () => execute(...args));
120+
};
121+
122+
instrumented.batch = (...args: unknown[]) => {
123+
const statements = Array.isArray(args[0]) ? args[0] : [];
124+
125+
return withDbBatchSpan(
126+
statements.map((statement) => sqlFromStatement(statement)),
127+
() => batch(...args),
128+
);
129+
};
130+
131+
return client;
132+
}
133+
134+
function withDbSpan<T>(
135+
sql: string | undefined,
136+
method: string,
137+
fn: () => Promise<T>,
138+
) {
139+
const operation = sqlOperation(sql) ?? method;
140+
const collection = sqlCollection(sql);
141+
142+
return withSpan(
143+
"db.query",
144+
dbQueryAttributes(operation, collection, method),
145+
async (span) => {
146+
if (await shouldInjectDbLatencySpike(operation, collection)) {
147+
span?.setAttribute("app.result", "latency_spike");
148+
await sleep(5000);
149+
}
150+
151+
return fn();
152+
},
153+
);
154+
}
155+
156+
function withDbBatchSpan<T>(
157+
sqlStatements: (string | undefined)[],
158+
fn: () => Promise<T>,
159+
) {
160+
return withSpan("db.batch", dbBatchAttributes(sqlStatements), async () =>
161+
fn(),
162+
);
163+
}
164+
165+
function dbQueryAttributes(
166+
operation: string,
167+
collection: string | undefined,
168+
method: string,
169+
) {
170+
return {
171+
"db.system": "sqlite",
172+
"db.operation": operation,
173+
"db.query.method": method,
174+
...(collection ? { "db.collection": collection } : {}),
175+
};
176+
}
177+
178+
function dbBatchAttributes(sqlStatements: (string | undefined)[]) {
179+
const operations = [
180+
...new Set(sqlStatements.map(sqlOperation).filter(isString)),
181+
];
182+
const collections = [
183+
...new Set(sqlStatements.map(sqlCollection).filter(isString)),
184+
];
185+
186+
return {
187+
"db.system": "sqlite",
188+
"db.operation": operations.length === 1 ? operations[0] : "batch",
189+
"db.statement_count": sqlStatements.length,
190+
...(collections.length === 1 ? { "db.collection": collections[0] } : {}),
191+
};
192+
}
193+
194+
async function shouldInjectDbLatencySpike(
195+
operation: string,
196+
collection: string | undefined,
197+
) {
198+
return (
199+
operation === "select" &&
200+
collection === "team_members" &&
201+
(await isRequestFaultActive("api-team-db-latency-spike"))
202+
);
203+
}
204+
205+
async function isRequestFaultActive(faultName: string) {
206+
try {
207+
const { headers } = await import("next/headers");
208+
return (await headers()).get("x-faults")?.trim() === faultName;
209+
} catch {
210+
return false;
211+
}
212+
}
213+
214+
function sleep(ms: number) {
215+
return new Promise((resolve) => setTimeout(resolve, ms));
216+
}
217+
218+
function sqlFromStatement(statement: unknown): string | undefined {
219+
if (typeof statement === "string") {
220+
return statement;
221+
}
222+
if (Array.isArray(statement) && typeof statement[0] === "string") {
223+
return statement[0];
224+
}
225+
if (isRecord(statement) && typeof statement.sql === "string") {
226+
return statement.sql;
227+
}
228+
229+
return undefined;
230+
}
231+
232+
function sqlOperation(sql: string | undefined) {
233+
return sql?.trim().split(/\s+/, 1)[0]?.toLowerCase();
234+
}
235+
236+
function sqlCollection(sql: string | undefined) {
237+
if (!sql) return undefined;
238+
239+
const normalized = sql.replace(/["`]/g, " ");
240+
const match = normalized.match(
241+
/\b(?:from|into|update|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)/i,
242+
);
243+
244+
return match?.[1]?.toLowerCase();
245+
}
246+
247+
function isRecord(value: unknown): value is Record<string, unknown> {
248+
return typeof value === "object" && value !== null;
249+
}
250+
251+
function isString(value: string | undefined): value is string {
252+
return typeof value === "string";
253+
}
254+
87255
function createDatabase() {
88256
if (databaseConfig.mode === "proxy") {
89257
return drizzleProxy(executeProxyQuery, executeProxyBatch, { schema });
@@ -100,7 +268,7 @@ function createDatabase() {
100268
},
101269
);
102270

103-
return drizzleLibsql(client, { schema });
271+
return drizzleLibsql(instrumentLibsqlClient(client), { schema });
104272
}
105273

106274
export const db = createDatabase();

lib/db/queries.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,12 @@ import {
1212

1313
export async function getUser() {
1414
const sessionCookie = (await cookies()).get("session");
15-
if (!sessionCookie || !sessionCookie.value) {
15+
if (!sessionCookie?.value) {
1616
return null;
1717
}
1818

1919
const sessionData = await verifyToken(sessionCookie.value);
20-
if (
21-
!sessionData ||
22-
!sessionData.user ||
23-
typeof sessionData.user.id !== "number"
24-
) {
20+
if (!sessionData?.user || typeof sessionData.user.id !== "number") {
2521
return null;
2622
}
2723

lib/telemetry.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {
2+
type Attributes,
3+
type Span,
4+
SpanStatusCode,
5+
trace,
6+
} from "@opentelemetry/api";
7+
8+
export function isTelemetryEnabled() {
9+
return Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
10+
}
11+
12+
export async function withSpan<T>(
13+
name: string,
14+
attributes: Attributes,
15+
fn: (span?: Span) => Promise<T>,
16+
) {
17+
if (!isTelemetryEnabled()) {
18+
return fn();
19+
}
20+
21+
const tracer = trace.getTracer("playwright-tutorial-next");
22+
23+
return tracer.startActiveSpan(name, { attributes }, async (span) => {
24+
try {
25+
const result = await fn(span);
26+
span.setStatus({ code: SpanStatusCode.OK });
27+
return result;
28+
} catch (error) {
29+
span.recordException(error as Error);
30+
span.setStatus({
31+
code: SpanStatusCode.ERROR,
32+
message: error instanceof Error ? error.message : String(error),
33+
});
34+
throw error;
35+
} finally {
36+
span.end();
37+
}
38+
});
39+
}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@
1616
},
1717
"dependencies": {
1818
"@libsql/client": "^0.17.4",
19+
"@opentelemetry/api": "^1.9.1",
1920
"@tailwindcss/postcss": "4.3.1",
2021
"@types/node": "^24.13.2",
2122
"@types/react": "19.2.17",
2223
"@types/react-dom": "19.2.3",
24+
"@vercel/otel": "^2.1.3",
2325
"bcryptjs": "^3.0.3",
2426
"class-variance-authority": "^0.7.1",
2527
"clsx": "^2.1.1",

0 commit comments

Comments
 (0)