Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/api/team/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
}
19 changes: 19 additions & 0 deletions instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -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",
},
});
}
10 changes: 5 additions & 5 deletions lib/auth/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<S extends z.ZodType<any, any>, T> = (
type ValidatedActionFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
) => Promise<T>;

export function validatedAction<S extends z.ZodType<any, any>, T>(
export function validatedAction<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionFunction<S, T>,
) {
Expand All@@ -28,13 +28,13 @@ export function validatedAction<S extends z.ZodType<any, any>, T>(
};
}

type ValidatedActionWithUserFunction<S extends z.ZodType<any, any>, T> = (
type ValidatedActionWithUserFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
user: User,
) => Promise<T>;

export function validatedActionWithUser<S extends z.ZodType<any, any>, T>(
export function validatedActionWithUser<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionWithUserFunction<S, T>,
) {
Expand Down
170 changes: 169 additions & 1 deletion lib/db/drizzle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";

Expand All@@ -14,6 +15,11 @@ dotenv.config();
type QueryMethod = Parameters<AsyncRemoteCallback>[2];
type BatchQuery = Parameters<AsyncBatchRemoteCallback>[0][number];
type ProxyQueryResult = Awaited<ReturnType<AsyncRemoteCallback>>;
type LibsqlClient = ReturnType<typeof createClient>;
type InstrumentableLibsqlClient = {
execute: (...args: unknown[]) => Promise<unknown>;
batch: (...args: unknown[]) => Promise<unknown>;
};

const databaseConfig = resolveRuntimeDatabaseConfig();

Expand All@@ -25,6 +31,16 @@ async function executeProxyQuery(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
return withDbSpan(sql, method, () =>
executeProxyQueryUntraced(sql, params, method),
);
}

async function executeProxyQueryUntraced(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand All@@ -51,6 +67,15 @@ async function executeProxyQuery(

async function executeProxyBatch(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
return withDbBatchSpan(
queries.map((query) => query.sql),
() => executeProxyBatchUntraced(queries),
);
}

async function executeProxyBatchUntraced(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand DownExpand Up@@ -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<T>(
sql: string | undefined,
method: string,
fn: () => Promise<T>,
) {
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<T>(
sqlStatements: (string | undefined)[],
fn: () => Promise<T>,
) {
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<string, unknown> {
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 });
Expand All@@ -100,7 +268,7 @@ function createDatabase() {
},
);

return drizzleLibsql(client, { schema });
return drizzleLibsql(instrumentLibsqlClient(client), { schema });
}

export const db = createDatabase();
8 changes: 2 additions & 6 deletions lib/db/queries.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
39 changes: 39 additions & 0 deletions lib/telemetry.ts
Original file line numberDiff line numberDiff line change
@@ -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<T>(
name: string,
attributes: Attributes,
fn: (span?: Span) => Promise<T>,
) {
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();
}
});
}
2 changes: 2 additions & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/api/team/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
}
19 changes: 19 additions & 0 deletions instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -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",
},
});
}
10 changes: 5 additions & 5 deletions lib/auth/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<S extends z.ZodType<any, any>, T> = (
type ValidatedActionFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
) => Promise<T>;

export function validatedAction<S extends z.ZodType<any, any>, T>(
export function validatedAction<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionFunction<S, T>,
) {
Expand All@@ -28,13 +28,13 @@ export function validatedAction<S extends z.ZodType<any, any>, T>(
};
}

type ValidatedActionWithUserFunction<S extends z.ZodType<any, any>, T> = (
type ValidatedActionWithUserFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
user: User,
) => Promise<T>;

export function validatedActionWithUser<S extends z.ZodType<any, any>, T>(
export function validatedActionWithUser<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionWithUserFunction<S, T>,
) {
Expand Down
170 changes: 169 additions & 1 deletion lib/db/drizzle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";

Expand All@@ -14,6 +15,11 @@ dotenv.config();
type QueryMethod = Parameters<AsyncRemoteCallback>[2];
type BatchQuery = Parameters<AsyncBatchRemoteCallback>[0][number];
type ProxyQueryResult = Awaited<ReturnType<AsyncRemoteCallback>>;
type LibsqlClient = ReturnType<typeof createClient>;
type InstrumentableLibsqlClient = {
execute: (...args: unknown[]) => Promise<unknown>;
batch: (...args: unknown[]) => Promise<unknown>;
};

const databaseConfig = resolveRuntimeDatabaseConfig();

Expand All@@ -25,6 +31,16 @@ async function executeProxyQuery(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
return withDbSpan(sql, method, () =>
executeProxyQueryUntraced(sql, params, method),
);
}

async function executeProxyQueryUntraced(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand All@@ -51,6 +67,15 @@ async function executeProxyQuery(

async function executeProxyBatch(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
return withDbBatchSpan(
queries.map((query) => query.sql),
() => executeProxyBatchUntraced(queries),
);
}

async function executeProxyBatchUntraced(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand DownExpand Up@@ -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<T>(
sql: string | undefined,
method: string,
fn: () => Promise<T>,
) {
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<T>(
sqlStatements: (string | undefined)[],
fn: () => Promise<T>,
) {
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<string, unknown> {
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 });
Expand All@@ -100,7 +268,7 @@ function createDatabase() {
},
);

return drizzleLibsql(client, { schema });
return drizzleLibsql(instrumentLibsqlClient(client), { schema });
}

export const db = createDatabase();
8 changes: 2 additions & 6 deletions lib/db/queries.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
39 changes: 39 additions & 0 deletions lib/telemetry.ts
Original file line numberDiff line numberDiff line change
@@ -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<T>(
name: string,
attributes: Attributes,
fn: (span?: Span) => Promise<T>,
) {
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();
}
});
}
2 changes: 2 additions & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/api/team/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
}
19 changes: 19 additions & 0 deletions instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -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",
},
});
}
10 changes: 5 additions & 5 deletions lib/auth/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<S extends z.ZodType<any, any>, T> = (
type ValidatedActionFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
) => Promise<T>;

export function validatedAction<S extends z.ZodType<any, any>, T>(
export function validatedAction<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionFunction<S, T>,
) {
Expand All@@ -28,13 +28,13 @@ export function validatedAction<S extends z.ZodType<any, any>, T>(
};
}

type ValidatedActionWithUserFunction<S extends z.ZodType<any, any>, T> = (
type ValidatedActionWithUserFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
user: User,
) => Promise<T>;

export function validatedActionWithUser<S extends z.ZodType<any, any>, T>(
export function validatedActionWithUser<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionWithUserFunction<S, T>,
) {
Expand Down
170 changes: 169 additions & 1 deletion lib/db/drizzle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";

Expand All@@ -14,6 +15,11 @@ dotenv.config();
type QueryMethod = Parameters<AsyncRemoteCallback>[2];
type BatchQuery = Parameters<AsyncBatchRemoteCallback>[0][number];
type ProxyQueryResult = Awaited<ReturnType<AsyncRemoteCallback>>;
type LibsqlClient = ReturnType<typeof createClient>;
type InstrumentableLibsqlClient = {
execute: (...args: unknown[]) => Promise<unknown>;
batch: (...args: unknown[]) => Promise<unknown>;
};

const databaseConfig = resolveRuntimeDatabaseConfig();

Expand All@@ -25,6 +31,16 @@ async function executeProxyQuery(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
return withDbSpan(sql, method, () =>
executeProxyQueryUntraced(sql, params, method),
);
}

async function executeProxyQueryUntraced(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand All@@ -51,6 +67,15 @@ async function executeProxyQuery(

async function executeProxyBatch(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
return withDbBatchSpan(
queries.map((query) => query.sql),
() => executeProxyBatchUntraced(queries),
);
}

async function executeProxyBatchUntraced(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand DownExpand Up@@ -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<T>(
sql: string | undefined,
method: string,
fn: () => Promise<T>,
) {
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<T>(
sqlStatements: (string | undefined)[],
fn: () => Promise<T>,
) {
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<string, unknown> {
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 });
Expand All@@ -100,7 +268,7 @@ function createDatabase() {
},
);

return drizzleLibsql(client, { schema });
return drizzleLibsql(instrumentLibsqlClient(client), { schema });
}

export const db = createDatabase();
8 changes: 2 additions & 6 deletions lib/db/queries.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
39 changes: 39 additions & 0 deletions lib/telemetry.ts
Original file line numberDiff line numberDiff line change
@@ -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<T>(
name: string,
attributes: Attributes,
fn: (span?: Span) => Promise<T>,
) {
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();
}
});
}
2 changes: 2 additions & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/api/team/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
}
19 changes: 19 additions & 0 deletions instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -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",
},
});
}
10 changes: 5 additions & 5 deletions lib/auth/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<S extends z.ZodType<any, any>, T> = (
type ValidatedActionFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
) => Promise<T>;

export function validatedAction<S extends z.ZodType<any, any>, T>(
export function validatedAction<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionFunction<S, T>,
) {
Expand All@@ -28,13 +28,13 @@ export function validatedAction<S extends z.ZodType<any, any>, T>(
};
}

type ValidatedActionWithUserFunction<S extends z.ZodType<any, any>, T> = (
type ValidatedActionWithUserFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
user: User,
) => Promise<T>;

export function validatedActionWithUser<S extends z.ZodType<any, any>, T>(
export function validatedActionWithUser<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionWithUserFunction<S, T>,
) {
Expand Down
170 changes: 169 additions & 1 deletion lib/db/drizzle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";

Expand All@@ -14,6 +15,11 @@ dotenv.config();
type QueryMethod = Parameters<AsyncRemoteCallback>[2];
type BatchQuery = Parameters<AsyncBatchRemoteCallback>[0][number];
type ProxyQueryResult = Awaited<ReturnType<AsyncRemoteCallback>>;
type LibsqlClient = ReturnType<typeof createClient>;
type InstrumentableLibsqlClient = {
execute: (...args: unknown[]) => Promise<unknown>;
batch: (...args: unknown[]) => Promise<unknown>;
};

const databaseConfig = resolveRuntimeDatabaseConfig();

Expand All@@ -25,6 +31,16 @@ async function executeProxyQuery(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
return withDbSpan(sql, method, () =>
executeProxyQueryUntraced(sql, params, method),
);
}

async function executeProxyQueryUntraced(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand All@@ -51,6 +67,15 @@ async function executeProxyQuery(

async function executeProxyBatch(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
return withDbBatchSpan(
queries.map((query) => query.sql),
() => executeProxyBatchUntraced(queries),
);
}

async function executeProxyBatchUntraced(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand DownExpand Up@@ -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<T>(
sql: string | undefined,
method: string,
fn: () => Promise<T>,
) {
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<T>(
sqlStatements: (string | undefined)[],
fn: () => Promise<T>,
) {
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<string, unknown> {
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 });
Expand All@@ -100,7 +268,7 @@ function createDatabase() {
},
);

return drizzleLibsql(client, { schema });
return drizzleLibsql(instrumentLibsqlClient(client), { schema });
}

export const db = createDatabase();
8 changes: 2 additions & 6 deletions lib/db/queries.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
39 changes: 39 additions & 0 deletions lib/telemetry.ts
Original file line numberDiff line numberDiff line change
@@ -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<T>(
name: string,
attributes: Attributes,
fn: (span?: Span) => Promise<T>,
) {
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();
}
});
}
2 changes: 2 additions & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/api/team/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
}
19 changes: 19 additions & 0 deletions instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -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",
},
});
}
10 changes: 5 additions & 5 deletions lib/auth/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<S extends z.ZodType<any, any>, T> = (
type ValidatedActionFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
) => Promise<T>;

export function validatedAction<S extends z.ZodType<any, any>, T>(
export function validatedAction<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionFunction<S, T>,
) {
Expand All@@ -28,13 +28,13 @@ export function validatedAction<S extends z.ZodType<any, any>, T>(
};
}

type ValidatedActionWithUserFunction<S extends z.ZodType<any, any>, T> = (
type ValidatedActionWithUserFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
user: User,
) => Promise<T>;

export function validatedActionWithUser<S extends z.ZodType<any, any>, T>(
export function validatedActionWithUser<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionWithUserFunction<S, T>,
) {
Expand Down
170 changes: 169 additions & 1 deletion lib/db/drizzle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";

Expand All@@ -14,6 +15,11 @@ dotenv.config();
type QueryMethod = Parameters<AsyncRemoteCallback>[2];
type BatchQuery = Parameters<AsyncBatchRemoteCallback>[0][number];
type ProxyQueryResult = Awaited<ReturnType<AsyncRemoteCallback>>;
type LibsqlClient = ReturnType<typeof createClient>;
type InstrumentableLibsqlClient = {
execute: (...args: unknown[]) => Promise<unknown>;
batch: (...args: unknown[]) => Promise<unknown>;
};

const databaseConfig = resolveRuntimeDatabaseConfig();

Expand All@@ -25,6 +31,16 @@ async function executeProxyQuery(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
return withDbSpan(sql, method, () =>
executeProxyQueryUntraced(sql, params, method),
);
}

async function executeProxyQueryUntraced(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand All@@ -51,6 +67,15 @@ async function executeProxyQuery(

async function executeProxyBatch(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
return withDbBatchSpan(
queries.map((query) => query.sql),
() => executeProxyBatchUntraced(queries),
);
}

async function executeProxyBatchUntraced(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand DownExpand Up@@ -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<T>(
sql: string | undefined,
method: string,
fn: () => Promise<T>,
) {
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<T>(
sqlStatements: (string | undefined)[],
fn: () => Promise<T>,
) {
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<string, unknown> {
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 });
Expand All@@ -100,7 +268,7 @@ function createDatabase() {
},
);

return drizzleLibsql(client, { schema });
return drizzleLibsql(instrumentLibsqlClient(client), { schema });
}

export const db = createDatabase();
8 changes: 2 additions & 6 deletions lib/db/queries.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
39 changes: 39 additions & 0 deletions lib/telemetry.ts
Original file line numberDiff line numberDiff line change
@@ -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<T>(
name: string,
attributes: Attributes,
fn: (span?: Span) => Promise<T>,
) {
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();
}
});
}
2 changes: 2 additions & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/api/team/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
}
19 changes: 19 additions & 0 deletions instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -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",
},
});
}
10 changes: 5 additions & 5 deletions lib/auth/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<S extends z.ZodType<any, any>, T> = (
type ValidatedActionFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
) => Promise<T>;

export function validatedAction<S extends z.ZodType<any, any>, T>(
export function validatedAction<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionFunction<S, T>,
) {
Expand All@@ -28,13 +28,13 @@ export function validatedAction<S extends z.ZodType<any, any>, T>(
};
}

type ValidatedActionWithUserFunction<S extends z.ZodType<any, any>, T> = (
type ValidatedActionWithUserFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
user: User,
) => Promise<T>;

export function validatedActionWithUser<S extends z.ZodType<any, any>, T>(
export function validatedActionWithUser<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionWithUserFunction<S, T>,
) {
Expand Down
170 changes: 169 additions & 1 deletion lib/db/drizzle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";

Expand All@@ -14,6 +15,11 @@ dotenv.config();
type QueryMethod = Parameters<AsyncRemoteCallback>[2];
type BatchQuery = Parameters<AsyncBatchRemoteCallback>[0][number];
type ProxyQueryResult = Awaited<ReturnType<AsyncRemoteCallback>>;
type LibsqlClient = ReturnType<typeof createClient>;
type InstrumentableLibsqlClient = {
execute: (...args: unknown[]) => Promise<unknown>;
batch: (...args: unknown[]) => Promise<unknown>;
};

const databaseConfig = resolveRuntimeDatabaseConfig();

Expand All@@ -25,6 +31,16 @@ async function executeProxyQuery(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
return withDbSpan(sql, method, () =>
executeProxyQueryUntraced(sql, params, method),
);
}

async function executeProxyQueryUntraced(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand All@@ -51,6 +67,15 @@ async function executeProxyQuery(

async function executeProxyBatch(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
return withDbBatchSpan(
queries.map((query) => query.sql),
() => executeProxyBatchUntraced(queries),
);
}

async function executeProxyBatchUntraced(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand DownExpand Up@@ -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<T>(
sql: string | undefined,
method: string,
fn: () => Promise<T>,
) {
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<T>(
sqlStatements: (string | undefined)[],
fn: () => Promise<T>,
) {
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<string, unknown> {
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 });
Expand All@@ -100,7 +268,7 @@ function createDatabase() {
},
);

return drizzleLibsql(client, { schema });
return drizzleLibsql(instrumentLibsqlClient(client), { schema });
}

export const db = createDatabase();
8 changes: 2 additions & 6 deletions lib/db/queries.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
39 changes: 39 additions & 0 deletions lib/telemetry.ts
Original file line numberDiff line numberDiff line change
@@ -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<T>(
name: string,
attributes: Attributes,
fn: (span?: Span) => Promise<T>,
) {
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();
}
});
}
2 changes: 2 additions & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/api/team/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
}
19 changes: 19 additions & 0 deletions instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -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",
},
});
}
10 changes: 5 additions & 5 deletions lib/auth/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<S extends z.ZodType<any, any>, T> = (
type ValidatedActionFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
) => Promise<T>;

export function validatedAction<S extends z.ZodType<any, any>, T>(
export function validatedAction<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionFunction<S, T>,
) {
Expand All@@ -28,13 +28,13 @@ export function validatedAction<S extends z.ZodType<any, any>, T>(
};
}

type ValidatedActionWithUserFunction<S extends z.ZodType<any, any>, T> = (
type ValidatedActionWithUserFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
user: User,
) => Promise<T>;

export function validatedActionWithUser<S extends z.ZodType<any, any>, T>(
export function validatedActionWithUser<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionWithUserFunction<S, T>,
) {
Expand Down
170 changes: 169 additions & 1 deletion lib/db/drizzle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";

Expand All@@ -14,6 +15,11 @@ dotenv.config();
type QueryMethod = Parameters<AsyncRemoteCallback>[2];
type BatchQuery = Parameters<AsyncBatchRemoteCallback>[0][number];
type ProxyQueryResult = Awaited<ReturnType<AsyncRemoteCallback>>;
type LibsqlClient = ReturnType<typeof createClient>;
type InstrumentableLibsqlClient = {
execute: (...args: unknown[]) => Promise<unknown>;
batch: (...args: unknown[]) => Promise<unknown>;
};

const databaseConfig = resolveRuntimeDatabaseConfig();

Expand All@@ -25,6 +31,16 @@ async function executeProxyQuery(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
return withDbSpan(sql, method, () =>
executeProxyQueryUntraced(sql, params, method),
);
}

async function executeProxyQueryUntraced(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand All@@ -51,6 +67,15 @@ async function executeProxyQuery(

async function executeProxyBatch(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
return withDbBatchSpan(
queries.map((query) => query.sql),
() => executeProxyBatchUntraced(queries),
);
}

async function executeProxyBatchUntraced(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand DownExpand Up@@ -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<T>(
sql: string | undefined,
method: string,
fn: () => Promise<T>,
) {
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<T>(
sqlStatements: (string | undefined)[],
fn: () => Promise<T>,
) {
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<string, unknown> {
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 });
Expand All@@ -100,7 +268,7 @@ function createDatabase() {
},
);

return drizzleLibsql(client, { schema });
return drizzleLibsql(instrumentLibsqlClient(client), { schema });
}

export const db = createDatabase();
8 changes: 2 additions & 6 deletions lib/db/queries.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
39 changes: 39 additions & 0 deletions lib/telemetry.ts
Original file line numberDiff line numberDiff line change
@@ -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<T>(
name: string,
attributes: Attributes,
fn: (span?: Span) => Promise<T>,
) {
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();
}
});
}
2 changes: 2 additions & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/api/team/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
}
19 changes: 19 additions & 0 deletions instrumentation.ts
Original file line numberDiff line numberDiff line change
@@ -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",
},
});
}
10 changes: 5 additions & 5 deletions lib/auth/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<S extends z.ZodType<any, any>, T> = (
type ValidatedActionFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
) => Promise<T>;

export function validatedAction<S extends z.ZodType<any, any>, T>(
export function validatedAction<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionFunction<S, T>,
) {
Expand All@@ -28,13 +28,13 @@ export function validatedAction<S extends z.ZodType<any, any>, T>(
};
}

type ValidatedActionWithUserFunction<S extends z.ZodType<any, any>, T> = (
type ValidatedActionWithUserFunction<S extends z.ZodType, T> = (
data: z.infer<S>,
formData: FormData,
user: User,
) => Promise<T>;

export function validatedActionWithUser<S extends z.ZodType<any, any>, T>(
export function validatedActionWithUser<S extends z.ZodType, T>(
schema: S,
action: ValidatedActionWithUserFunction<S, T>,
) {
Expand Down
170 changes: 169 additions & 1 deletion lib/db/drizzle.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";

Expand All@@ -14,6 +15,11 @@ dotenv.config();
type QueryMethod = Parameters<AsyncRemoteCallback>[2];
type BatchQuery = Parameters<AsyncBatchRemoteCallback>[0][number];
type ProxyQueryResult = Awaited<ReturnType<AsyncRemoteCallback>>;
type LibsqlClient = ReturnType<typeof createClient>;
type InstrumentableLibsqlClient = {
execute: (...args: unknown[]) => Promise<unknown>;
batch: (...args: unknown[]) => Promise<unknown>;
};

const databaseConfig = resolveRuntimeDatabaseConfig();

Expand All@@ -25,6 +31,16 @@ async function executeProxyQuery(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
return withDbSpan(sql, method, () =>
executeProxyQueryUntraced(sql, params, method),
);
}

async function executeProxyQueryUntraced(
sql: string,
params: unknown[],
method: QueryMethod,
): Promise<ProxyQueryResult> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand All@@ -51,6 +67,15 @@ async function executeProxyQuery(

async function executeProxyBatch(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
return withDbBatchSpan(
queries.map((query) => query.sql),
() => executeProxyBatchUntraced(queries),
);
}

async function executeProxyBatchUntraced(
queries: BatchQuery[],
): Promise<ProxyQueryResult[]> {
const proxyUrl =
databaseConfig.mode === "proxy" ? databaseConfig.proxyUrl : undefined;
Expand DownExpand Up@@ -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<T>(
sql: string | undefined,
method: string,
fn: () => Promise<T>,
) {
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<T>(
sqlStatements: (string | undefined)[],
fn: () => Promise<T>,
) {
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<string, unknown> {
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 });
Expand All@@ -100,7 +268,7 @@ function createDatabase() {
},
);

return drizzleLibsql(client, { schema });
return drizzleLibsql(instrumentLibsqlClient(client), { schema });
}

export const db = createDatabase();
8 changes: 2 additions & 6 deletions lib/db/queries.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
39 changes: 39 additions & 0 deletions lib/telemetry.ts
Original file line numberDiff line numberDiff line change
@@ -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<T>(
name: string,
attributes: Attributes,
fn: (span?: Span) => Promise<T>,
) {
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();
}
});
}
2 changes: 2 additions & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading
Loading