diff --git a/app/api/team/route.ts b/app/api/team/route.ts index 4b0fd97..eea1980 100644 --- a/app/api/team/route.ts +++ b/app/api/team/route.ts @@ -12,6 +12,12 @@ export async function GET() { }); } + if (await isFaultActive("api-team-db-read-skipped")) { + return Response.json(null, { + headers: { "x-fault-injected": "api-team-db-read-skipped" }, + }); + } + const team = await getTeamForUser(); return Response.json(team); } diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 0000000..fd2dc99 --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,19 @@ +import { registerOTel } from "@vercel/otel"; + +function backendOtelEnabled() { + return Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT); +} + +export function register() { + if (!backendOtelEnabled()) { + return; + } + + registerOTel({ + serviceName: process.env.OTEL_SERVICE_NAME || "playwright-tutorial-next", + attributes: { + "service.namespace": "playwright-tutorial", + "endform.telemetry.source": "next-app", + }, + }); +} diff --git a/lib/auth/middleware.ts b/lib/auth/middleware.ts index 203f2ec..1694c0d 100644 --- a/lib/auth/middleware.ts +++ b/lib/auth/middleware.ts @@ -6,15 +6,15 @@ import type { TeamDataWithMembers, User } from "@/lib/db/schema"; export type ActionState = { error?: string; success?: string; - [key: string]: any; // This allows for additional properties + [key: string]: string | number | readonly string[] | undefined; }; -type ValidatedActionFunction, T> = ( +type ValidatedActionFunction = ( data: z.infer, formData: FormData, ) => Promise; -export function validatedAction, T>( +export function validatedAction( schema: S, action: ValidatedActionFunction, ) { @@ -28,13 +28,13 @@ export function validatedAction, T>( }; } -type ValidatedActionWithUserFunction, T> = ( +type ValidatedActionWithUserFunction = ( data: z.infer, formData: FormData, user: User, ) => Promise; -export function validatedActionWithUser, T>( +export function validatedActionWithUser( schema: S, action: ValidatedActionWithUserFunction, ) { diff --git a/lib/db/drizzle.ts b/lib/db/drizzle.ts index e7f940d..22bb5e2 100644 --- a/lib/db/drizzle.ts +++ b/lib/db/drizzle.ts @@ -6,6 +6,7 @@ import { type AsyncRemoteCallback, drizzle as drizzleProxy, } from "drizzle-orm/sqlite-proxy"; +import { withSpan } from "../telemetry"; import { resolveRuntimeDatabaseConfig } from "./config"; import * as schema from "./schema"; @@ -14,6 +15,11 @@ dotenv.config(); type QueryMethod = Parameters[2]; type BatchQuery = Parameters[0][number]; type ProxyQueryResult = Awaited>; +type LibsqlClient = ReturnType; +type InstrumentableLibsqlClient = { + execute: (...args: unknown[]) => Promise; + batch: (...args: unknown[]) => Promise; +}; const databaseConfig = resolveRuntimeDatabaseConfig(); @@ -25,6 +31,16 @@ async function executeProxyQuery( sql: string, params: unknown[], method: QueryMethod, +): Promise { + return withDbSpan(sql, method, () => + executeProxyQueryUntraced(sql, params, method), + ); +} + +async function executeProxyQueryUntraced( + sql: string, + params: unknown[], + method: QueryMethod, ): Promise { const proxyUrl = databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined; @@ -51,6 +67,15 @@ async function executeProxyQuery( async function executeProxyBatch( queries: BatchQuery[], +): Promise { + return withDbBatchSpan( + queries.map((query) => query.sql), + () => executeProxyBatchUntraced(queries), + ); +} + +async function executeProxyBatchUntraced( + queries: BatchQuery[], ): Promise { const proxyUrl = databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined; @@ -84,6 +109,149 @@ function ensureTrailingSlash(url: string) { return url.endsWith("/") ? url : `${url}/`; } +function instrumentLibsqlClient(client: LibsqlClient): LibsqlClient { + const instrumented = client as unknown as InstrumentableLibsqlClient; + const execute = instrumented.execute.bind(client); + const batch = instrumented.batch.bind(client); + + instrumented.execute = (...args: unknown[]) => { + const sql = sqlFromStatement(args[0]); + return withDbSpan(sql, "execute", () => execute(...args)); + }; + + instrumented.batch = (...args: unknown[]) => { + const statements = Array.isArray(args[0]) ? args[0] : []; + + return withDbBatchSpan( + statements.map((statement) => sqlFromStatement(statement)), + () => batch(...args), + ); + }; + + return client; +} + +function withDbSpan( + sql: string | undefined, + method: string, + fn: () => Promise, +) { + const operation = sqlOperation(sql) ?? method; + const collection = sqlCollection(sql); + + return withSpan( + "db.query", + dbQueryAttributes(operation, collection, method), + async (span) => { + if (await shouldInjectDbLatencySpike(operation, collection)) { + span?.setAttribute("app.result", "latency_spike"); + await sleep(5000); + } + + return fn(); + }, + ); +} + +function withDbBatchSpan( + sqlStatements: (string | undefined)[], + fn: () => Promise, +) { + return withSpan("db.batch", dbBatchAttributes(sqlStatements), async () => + fn(), + ); +} + +function dbQueryAttributes( + operation: string, + collection: string | undefined, + method: string, +) { + return { + "db.system": "sqlite", + "db.operation": operation, + "db.query.method": method, + ...(collection ? { "db.collection": collection } : {}), + }; +} + +function dbBatchAttributes(sqlStatements: (string | undefined)[]) { + const operations = [ + ...new Set(sqlStatements.map(sqlOperation).filter(isString)), + ]; + const collections = [ + ...new Set(sqlStatements.map(sqlCollection).filter(isString)), + ]; + + return { + "db.system": "sqlite", + "db.operation": operations.length === 1 ? operations[0] : "batch", + "db.statement_count": sqlStatements.length, + ...(collections.length === 1 ? { "db.collection": collections[0] } : {}), + }; +} + +async function shouldInjectDbLatencySpike( + operation: string, + collection: string | undefined, +) { + return ( + operation === "select" && + collection === "team_members" && + (await isRequestFaultActive("api-team-db-latency-spike")) + ); +} + +async function isRequestFaultActive(faultName: string) { + try { + const { headers } = await import("next/headers"); + return (await headers()).get("x-faults")?.trim() === faultName; + } catch { + return false; + } +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function sqlFromStatement(statement: unknown): string | undefined { + if (typeof statement === "string") { + return statement; + } + if (Array.isArray(statement) && typeof statement[0] === "string") { + return statement[0]; + } + if (isRecord(statement) && typeof statement.sql === "string") { + return statement.sql; + } + + return undefined; +} + +function sqlOperation(sql: string | undefined) { + return sql?.trim().split(/\s+/, 1)[0]?.toLowerCase(); +} + +function sqlCollection(sql: string | undefined) { + if (!sql) return undefined; + + const normalized = sql.replace(/["`]/g, " "); + const match = normalized.match( + /\b(?:from|into|update|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)/i, + ); + + return match?.[1]?.toLowerCase(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isString(value: string | undefined): value is string { + return typeof value === "string"; +} + function createDatabase() { if (databaseConfig.mode === "proxy") { return drizzleProxy(executeProxyQuery, executeProxyBatch, { schema }); @@ -100,7 +268,7 @@ function createDatabase() { }, ); - return drizzleLibsql(client, { schema }); + return drizzleLibsql(instrumentLibsqlClient(client), { schema }); } export const db = createDatabase(); diff --git a/lib/db/queries.ts b/lib/db/queries.ts index c0d1c18..1e88f16 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -12,16 +12,12 @@ import { export async function getUser() { const sessionCookie = (await cookies()).get("session"); - if (!sessionCookie || !sessionCookie.value) { + if (!sessionCookie?.value) { return null; } const sessionData = await verifyToken(sessionCookie.value); - if ( - !sessionData || - !sessionData.user || - typeof sessionData.user.id !== "number" - ) { + if (!sessionData?.user || typeof sessionData.user.id !== "number") { return null; } diff --git a/lib/telemetry.ts b/lib/telemetry.ts new file mode 100644 index 0000000..9f5c059 --- /dev/null +++ b/lib/telemetry.ts @@ -0,0 +1,39 @@ +import { + type Attributes, + type Span, + SpanStatusCode, + trace, +} from "@opentelemetry/api"; + +export function isTelemetryEnabled() { + return Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT); +} + +export async function withSpan( + name: string, + attributes: Attributes, + fn: (span?: Span) => Promise, +) { + if (!isTelemetryEnabled()) { + return fn(); + } + + const tracer = trace.getTracer("playwright-tutorial-next"); + + return tracer.startActiveSpan(name, { attributes }, async (span) => { + try { + const result = await fn(span); + span.setStatus({ code: SpanStatusCode.OK }); + return result; + } catch (error) { + span.recordException(error as Error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + span.end(); + } + }); +} diff --git a/package.json b/package.json index 78abeeb..5fa7c14 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ }, "dependencies": { "@libsql/client": "^0.17.4", + "@opentelemetry/api": "^1.9.1", "@tailwindcss/postcss": "4.3.1", "@types/node": "^24.13.2", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", + "@vercel/otel": "^2.1.3", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15c9dc5..1a1321c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@libsql/client': specifier: ^0.17.4 version: 0.17.4 + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 '@tailwindcss/postcss': specifier: 4.3.1 version: 4.3.1 @@ -23,6 +26,9 @@ importers: '@types/react-dom': specifier: 19.2.3 version: 19.2.3(@types/react@19.2.17) + '@vercel/otel': + specifier: ^2.1.3 + version: 2.1.3(@opentelemetry/api-logs@0.219.0)(@opentelemetry/api@1.9.1)(@opentelemetry/instrumentation@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)) bcryptjs: specifier: ^3.0.3 version: 3.0.3 @@ -40,7 +46,7 @@ importers: version: 0.31.10 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@libsql/client@0.17.4)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.2.0)(bun-types@1.3.14)(gel@2.1.0)(postgres@3.4.5) + version: 0.45.2(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.2.0)(bun-types@1.3.14)(gel@2.1.0)(postgres@3.4.5) jose: specifier: ^6.2.3 version: 6.2.3 @@ -49,7 +55,7 @@ importers: version: 1.21.0(react@19.2.7) next: specifier: 16.2.9 - version: 16.2.9(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.2.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) postcss: specifier: ^8.5.15 version: 8.5.15 @@ -1079,6 +1085,54 @@ packages: cpu: [x64] os: [win32] + '@opentelemetry/api-logs@0.219.0': + resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.8.0': + resolution: {integrity: sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/instrumentation@0.219.0': + resolution: {integrity: sha512-X5t7I8GyIO9rmGHwoedZLREpQqrF1WW2nxzNNym6HOKpFiE+rvqV3ngC0xcZVO2YwIGf3KKmRdWrYwdwz3H9RQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.8.0': + resolution: {integrity: sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.219.0': + resolution: {integrity: sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.8.0': + resolution: {integrity: sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.8.0': + resolution: {integrity: sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + '@petamoriken/float16@3.9.3': resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} @@ -1895,10 +1949,32 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vercel/otel@2.1.3': + resolution: {integrity: sha512-Ofvzs9qhftRD1YMLuPnhbXjQZG6IKrJ9AmEKmRHRGfoWlV89ed2gAOkvddkFiZDFZJm2rrFNdeZKRVxoCdnWiw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <2.0.0' + '@opentelemetry/api-logs': '>=0.200.0 <0.300.0' + '@opentelemetry/instrumentation': '>=0.200.0 <0.300.0' + '@opentelemetry/resources': '>=2.0.0 <3.0.0' + '@opentelemetry/sdk-logs': '>=0.200.0 <0.300.0' + '@opentelemetry/sdk-metrics': '>=2.0.0 <3.0.0' + '@opentelemetry/sdk-trace-base': '>=2.0.0 <3.0.0' + '@zip.js/zip.js@2.8.26': resolution: {integrity: sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1940,6 +2016,9 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2181,6 +2260,10 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + import-in-the-middle@3.1.0: + resolution: {integrity: sha512-c0AeAV8VcwZzfYE7euTZY3H+VXUPMVugiovdosq80lqEXJmOekg3zGUAYg6KImHMaMuBoTUfTv7xNpUFdy0hJA==} + engines: {node: '>=18'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2298,6 +2381,9 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2444,6 +2530,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -3184,6 +3274,55 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.9': optional: true + '@opentelemetry/api-logs@0.219.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/instrumentation@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + import-in-the-middle: 3.1.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/semantic-conventions@1.41.1': {} + '@petamoriken/float16@3.9.3': optional: true @@ -4037,8 +4176,24 @@ snapshots: dependencies: '@types/node': 25.0.3 + '@vercel/otel@2.1.3(@opentelemetry/api-logs@0.219.0)(@opentelemetry/api@1.9.1)(@opentelemetry/instrumentation@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + '@zip.js/zip.js@2.8.26': {} + acorn-import-attributes@1.9.5(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -4085,6 +4240,8 @@ snapshots: chownr@1.1.4: optional: true + cjs-module-lexer@2.2.0: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -4098,7 +4255,6 @@ snapshots: debug@4.4.3: dependencies: ms: 2.1.3 - optional: true decompress-response@6.0.0: dependencies: @@ -4125,9 +4281,10 @@ snapshots: esbuild: 0.25.12 tsx: 4.21.0 - drizzle-orm@0.45.2(@libsql/client@0.17.4)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.2.0)(bun-types@1.3.14)(gel@2.1.0)(postgres@3.4.5): + drizzle-orm@0.45.2(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.2.0)(bun-types@1.3.14)(gel@2.1.0)(postgres@3.4.5): optionalDependencies: '@libsql/client': 0.17.4 + '@opentelemetry/api': 1.9.1 '@types/better-sqlite3': 7.6.13 better-sqlite3: 12.2.0 bun-types: 1.3.14 @@ -4319,6 +4476,13 @@ snapshots: ieee754@1.2.1: optional: true + import-in-the-middle@3.1.0: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + inherits@2.0.4: optional: true @@ -4415,8 +4579,9 @@ snapshots: mkdirp-classic@0.5.3: optional: true - ms@2.1.3: - optional: true + module-details-from-path@1.0.4: {} + + ms@2.1.3: {} nanoid@3.3.11: {} @@ -4425,7 +4590,7 @@ snapshots: napi-build-utils@2.0.0: optional: true - next@16.2.9(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.2.9(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.9 '@swc/helpers': 0.5.15 @@ -4444,6 +4609,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.9 '@next/swc-win32-arm64-msvc': 16.2.9 '@next/swc-win32-x64-msvc': 16.2.9 + '@opentelemetry/api': 1.9.1 '@playwright/test': 1.61.0 sharp: 0.34.5 transitivePeerDependencies: @@ -4626,6 +4792,13 @@ snapshots: util-deprecate: 1.0.2 optional: true + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + resolve-pkg-maps@1.0.0: {} safe-buffer@5.2.1: diff --git a/tests/support/faults.ts b/tests/support/faults.ts index 8e6c6f0..7c4d1b0 100644 --- a/tests/support/faults.ts +++ b/tests/support/faults.ts @@ -2,6 +2,9 @@ import type { Page, TestInfo } from "@playwright/test"; export const implementedFaults = [ "api-team-500", + "api-team-extra-request", + "api-team-db-latency-spike", + "api-team-db-read-skipped", "api-team-latency-spike", "api-team-malformed-json", "api-user-malformed-json", @@ -36,6 +39,15 @@ const faultTargets: Record = { "api-team-500": [ "should change user name in general settings and verify it appears in team members list", ], + "api-team-extra-request": [ + "should change user name in general settings and verify it appears in team members list", + ], + "api-team-db-latency-spike": [ + "should change user name in general settings and verify it appears in team members list", + ], + "api-team-db-read-skipped": [ + "should change user name in general settings and verify it appears in team members list", + ], "api-team-latency-spike": [ "should change user name in general settings and verify it appears in team members list", ], @@ -116,6 +128,9 @@ const FAULT_HEADER = "x-faults"; const faultInstallers: Record Promise> = { "api-team-500": installApiTeam500, + "api-team-extra-request": installApiTeamExtraRequest, + "api-team-db-latency-spike": installApiTeamDbLatencySpike, + "api-team-db-read-skipped": installApiTeamDbReadSkipped, "api-team-latency-spike": installApiTeamLatencySpike, "api-team-malformed-json": installApiTeamMalformedJson, "api-user-malformed-json": installApiUserMalformedJson, @@ -188,6 +203,44 @@ async function installApiTeam500(page: Page) { }); } +async function installApiTeamExtraRequest(page: Page) { + await page.addInitScript(() => { + const originalFetch = window.fetch.bind(window) as typeof window.fetch; + let duplicated = false; + + const faultFetch = Object.assign( + async (...args: Parameters) => { + const [input] = args; + const response = await originalFetch(...args); + const url = + typeof input === "string" || input instanceof URL + ? input.toString() + : input.url; + + if ( + !duplicated && + new URL(url, window.location.href).pathname === "/api/team" + ) { + duplicated = true; + void originalFetch(...args).catch(() => undefined); + } + + return response; + }, + originalFetch, + ) as typeof window.fetch; + window.fetch = faultFetch; + }); +} + +async function installApiTeamDbLatencySpike(page: Page) { + await setAppFaultHeader(page, "api-team-db-latency-spike"); +} + +async function installApiTeamDbReadSkipped(page: Page) { + await setAppFaultHeader(page, "api-team-db-read-skipped"); +} + async function installApiTeamMalformedJson(page: Page) { const faultName = "api-team-malformed-json";