Instrument API timing and reduce detail fallbacks
This commit is contained in:
+55
-15
@@ -5,6 +5,10 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
import {
|
||||
finishProxyTimedResponse,
|
||||
type ProxyTimer,
|
||||
} from "@/lib/proxy-timing";
|
||||
|
||||
const PASSTHROUGH_UPSTREAM_STATUSES = new Set([
|
||||
400,
|
||||
@@ -91,31 +95,59 @@ export async function proxyBackendJsonGet(
|
||||
signal?: AbortSignal;
|
||||
statusOnException?: number;
|
||||
timeoutPublicMessage?: string;
|
||||
timing?: ProxyTimer;
|
||||
url: string;
|
||||
},
|
||||
) {
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
const timing = options.timing;
|
||||
try {
|
||||
auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: options.includeSupabaseIdentity ?? false,
|
||||
});
|
||||
const res = await fetch(options.url, {
|
||||
headers: auth.headers,
|
||||
...(options.fetchCache
|
||||
? { cache: options.fetchCache }
|
||||
: { next: { revalidate: options.revalidateSeconds ?? 30 } }),
|
||||
signal: options.signal,
|
||||
});
|
||||
auth = await (timing
|
||||
? timing.measure("auth_headers", () =>
|
||||
buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: options.includeSupabaseIdentity ?? false,
|
||||
}),
|
||||
)
|
||||
: buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: options.includeSupabaseIdentity ?? false,
|
||||
}));
|
||||
const res = await (timing
|
||||
? timing.measure("backend_fetch", () =>
|
||||
fetch(options.url, {
|
||||
headers: auth!.headers,
|
||||
...(options.fetchCache
|
||||
? { cache: options.fetchCache }
|
||||
: { next: { revalidate: options.revalidateSeconds ?? 30 } }),
|
||||
signal: options.signal,
|
||||
}),
|
||||
)
|
||||
: fetch(options.url, {
|
||||
headers: auth.headers,
|
||||
...(options.fetchCache
|
||||
? { cache: options.fetchCache }
|
||||
: { next: { revalidate: options.revalidateSeconds ?? 30 } }),
|
||||
signal: options.signal,
|
||||
}));
|
||||
const backendServerTiming = res.headers.get("server-timing") || "";
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const raw = await (timing
|
||||
? timing.measure("backend_read", () => res.text())
|
||||
: res.text());
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: options.detailLimit,
|
||||
error: options.error,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
const withCookies = applyAuthResponseCookies(response, auth.response);
|
||||
return timing
|
||||
? finishProxyTimedResponse(withCookies, timing, `upstream_${res.status}`, {
|
||||
backendServerTiming,
|
||||
})
|
||||
: withCookies;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const data = await (timing
|
||||
? timing.measure("backend_read", () => res.json())
|
||||
: res.json());
|
||||
const response =
|
||||
options.cacheControl && options.conditionalResponse !== false
|
||||
? buildCachedJsonResponse(req, data, options.cacheControl)
|
||||
@@ -124,7 +156,12 @@ export async function proxyBackendJsonGet(
|
||||
? { "Cache-Control": options.cacheControl }
|
||||
: undefined,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
const withCookies = applyAuthResponseCookies(response, auth.response);
|
||||
return timing
|
||||
? finishProxyTimedResponse(withCookies, timing, "ok", {
|
||||
backendServerTiming,
|
||||
})
|
||||
: withCookies;
|
||||
} catch (error) {
|
||||
const timedOut = options.signal?.aborted === true;
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
@@ -134,6 +171,9 @@ export async function proxyBackendJsonGet(
|
||||
: options.publicMessage,
|
||||
status: timedOut ? 504 : options.statusOnException,
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
const withCookies = auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
return timing
|
||||
? finishProxyTimedResponse(withCookies, timing, timedOut ? "timeout" : "exception")
|
||||
: withCookies;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,6 +716,7 @@ export interface AiAnalysisStructured {
|
||||
}
|
||||
|
||||
export interface CityDetail {
|
||||
city?: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
detail_depth?: "panel" | "market" | "nearby" | "full";
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export type ProxyTimingStage = {
|
||||
durationMs: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ProxyTimer = {
|
||||
hasAuthorization: boolean;
|
||||
hasSupabaseCookie: boolean;
|
||||
measure<T>(name: string, action: () => Promise<T>): Promise<T>;
|
||||
measureSync<T>(name: string, action: () => T): T;
|
||||
route: string;
|
||||
stages: ProxyTimingStage[];
|
||||
totalMs(): number;
|
||||
};
|
||||
|
||||
function proxyNowMs() {
|
||||
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function hasRequestSupabaseSessionCookie(req: NextRequest) {
|
||||
return req.cookies.getAll().some((cookie) => {
|
||||
const name = cookie.name.toLowerCase();
|
||||
const value = String(cookie.value || "").trim();
|
||||
return Boolean(
|
||||
value &&
|
||||
(name === "supabase-auth-token" ||
|
||||
(name.startsWith("sb-") && name.includes("-auth-token"))),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function createProxyTimer(req: NextRequest, route: string): ProxyTimer {
|
||||
const startedAt = proxyNowMs();
|
||||
const stages: ProxyTimingStage[] = [];
|
||||
const recordStage = (name: string, stageStartedAt: number) => {
|
||||
stages.push({
|
||||
durationMs: Math.round((proxyNowMs() - stageStartedAt) * 10) / 10,
|
||||
name,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
hasAuthorization: Boolean(req.headers.get("authorization")),
|
||||
hasSupabaseCookie: hasRequestSupabaseSessionCookie(req),
|
||||
async measure<T>(name: string, action: () => Promise<T>) {
|
||||
const stageStartedAt = proxyNowMs();
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
recordStage(name, stageStartedAt);
|
||||
}
|
||||
},
|
||||
measureSync<T>(name: string, action: () => T) {
|
||||
const stageStartedAt = proxyNowMs();
|
||||
try {
|
||||
return action();
|
||||
} finally {
|
||||
recordStage(name, stageStartedAt);
|
||||
}
|
||||
},
|
||||
route,
|
||||
stages,
|
||||
totalMs() {
|
||||
return Math.round((proxyNowMs() - startedAt) * 10) / 10;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatServerTiming(stages: ProxyTimingStage[]) {
|
||||
return stages
|
||||
.map(({ durationMs, name }) => {
|
||||
const safeName = name.replace(/[^A-Za-z0-9_-]/g, "_");
|
||||
return `${safeName};dur=${Math.max(0, durationMs).toFixed(1)}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
export function finishProxyTimedResponse(
|
||||
response: NextResponse,
|
||||
timer: ProxyTimer,
|
||||
outcome: string,
|
||||
extra?: { backendServerTiming?: string },
|
||||
) {
|
||||
const total = timer.totalMs();
|
||||
const ownServerTiming = formatServerTiming(
|
||||
[...timer.stages, { durationMs: total, name: "total" }].map((stage) => ({
|
||||
...stage,
|
||||
name: `${timer.route}_${stage.name}`,
|
||||
})),
|
||||
);
|
||||
const backendServerTiming = String(extra?.backendServerTiming || "").trim();
|
||||
response.headers.set(
|
||||
"Server-Timing",
|
||||
backendServerTiming
|
||||
? `${ownServerTiming}, ${backendServerTiming}`
|
||||
: ownServerTiming,
|
||||
);
|
||||
console.info(
|
||||
"[api-proxy-timing]",
|
||||
JSON.stringify({
|
||||
hasAuthorization: timer.hasAuthorization,
|
||||
hasSupabaseCookie: timer.hasSupabaseCookie,
|
||||
outcome,
|
||||
route: timer.route,
|
||||
stages: timer.stages,
|
||||
status: response.status,
|
||||
totalMs: total,
|
||||
}),
|
||||
);
|
||||
return response;
|
||||
}
|
||||
Reference in New Issue
Block a user