mirror of
https://github.com/jakejarvis/domainstack.io.git
synced 2026-09-11 05:05:34 -04:00
refactor: update logging levels for various procedures
- Changed logging levels from `warn` to `debug` for non-critical issues in the calendar and chat API routes to reduce log noise. - Updated error handling in the cron and screenshot routes to use `warn` for more significant issues while maintaining `debug` for less critical logs. - Introduced a new `RemoteDataUnavailableError` class to better categorize remote data fetch failures across services, enhancing error handling consistency. - Adjusted logging in the domain and provider routers to differentiate between expected and unexpected errors, improving clarity in logs.
This commit is contained in:
@@ -38,7 +38,7 @@ export async function GET(request: NextRequest) {
|
||||
const validation = await validateCalendarFeedToken(token);
|
||||
|
||||
if (!validation.valid) {
|
||||
logger.warn({ reason: validation.reason }, "invalid calendar feed token");
|
||||
logger.debug({ reason: validation.reason }, "invalid calendar feed token");
|
||||
|
||||
// Use same error message for both cases to prevent enumeration
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function GET(request: NextRequest, context: RouteContext<"/api/chat
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
userId = session?.user?.id ?? null;
|
||||
} catch (err) {
|
||||
logger.debug({ err }, "auth session check failed, treating as anonymous");
|
||||
logger.warn({ err }, "auth session check failed, treating as anonymous");
|
||||
}
|
||||
|
||||
const rateLimitConfig = userId ? RATE_LIMIT_AUTHENTICATED.stream : RATE_LIMIT_ANONYMOUS.stream;
|
||||
|
||||
@@ -58,7 +58,7 @@ export async function POST(request: Request) {
|
||||
userId = session?.user?.id ?? null;
|
||||
} catch (err) {
|
||||
// Auth error - treat as anonymous, but log for debugging
|
||||
logger.debug({ err }, "auth session check failed, treating as anonymous");
|
||||
logger.warn({ err }, "auth session check failed, treating as anonymous");
|
||||
}
|
||||
|
||||
// Apply rate limits based on auth status
|
||||
@@ -80,7 +80,7 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
rawBody = await bodyPromise;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "failed to read chat request body");
|
||||
logger.debug({ err }, "failed to read chat request body");
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request body" },
|
||||
{ status: 400, headers: { ...rateLimit.headers } },
|
||||
@@ -98,7 +98,7 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
body = JSON.parse(rawBody);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "invalid JSON in chat request body");
|
||||
logger.debug({ err }, "invalid JSON in chat request body");
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON in request body" },
|
||||
{ status: 400, headers: { ...rateLimit.headers } },
|
||||
@@ -124,7 +124,7 @@ export async function POST(request: Request) {
|
||||
const truncatedMessages = rawMessages.slice(-MAX_CONVERSATION_MESSAGES);
|
||||
const validatedMessages = await validateChatMessages(truncatedMessages);
|
||||
if (!validatedMessages.success) {
|
||||
logger.warn({ err: validatedMessages.error }, "chat history failed tool validation");
|
||||
logger.debug({ err: validatedMessages.error }, "chat history failed tool validation");
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Validation failed",
|
||||
|
||||
@@ -115,7 +115,7 @@ export async function GET(request: Request) {
|
||||
if (result.status === "fulfilled") {
|
||||
allDomains.push(...result.value);
|
||||
} else {
|
||||
logger.error({ err: result.reason, sourceUrl: sources[i] }, "Error fetching blocklist");
|
||||
logger.warn({ err: result.reason, sourceUrl: sources[i] }, "Error fetching blocklist");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,30 +58,34 @@ const sectionFetchers: Record<Section, (domain: string) => Promise<unknown>> = {
|
||||
/**
|
||||
* Check if a section is stale for a given domain.
|
||||
*/
|
||||
async function isSectionStale(domain: string, section: Section): Promise<boolean> {
|
||||
async function isSectionStale(
|
||||
domain: string,
|
||||
section: Section,
|
||||
): Promise<{ stale: boolean; failed: boolean }> {
|
||||
try {
|
||||
const result = await sectionCacheGetters[section](domain);
|
||||
return result.stale || result.data === null;
|
||||
return { stale: result.stale || result.data === null, failed: false };
|
||||
} catch (err) {
|
||||
logger.error({ domain, section, err }, "failed to check staleness, assuming stale");
|
||||
return true;
|
||||
return { stale: true, failed: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stale sections for a domain.
|
||||
*/
|
||||
async function getStaleSections(domain: string): Promise<Section[]> {
|
||||
async function getStaleSections(domain: string): Promise<{ sections: Section[]; failed: boolean }> {
|
||||
const staleChecks = await Promise.all(
|
||||
ALL_SECTIONS.map(async (section) => ({
|
||||
section,
|
||||
stale: await isSectionStale(domain, section),
|
||||
result: await isSectionStale(domain, section),
|
||||
})),
|
||||
);
|
||||
|
||||
return staleChecks
|
||||
.filter((c): c is { section: Section; stale: true } => c.stale)
|
||||
.map((c) => c.section);
|
||||
return {
|
||||
sections: staleChecks.filter(({ result }) => result.stale).map(({ section }) => section),
|
||||
failed: staleChecks.some(({ result }) => result.failed),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,14 +123,12 @@ export async function GET(request: Request) {
|
||||
// Check staleness for all domains in parallel
|
||||
await Promise.all(
|
||||
recentDomains.map(async (domain) => {
|
||||
try {
|
||||
const staleSections = await getStaleSections(domain);
|
||||
for (const section of staleSections) {
|
||||
jobs.push({ domain, section });
|
||||
}
|
||||
} catch (err) {
|
||||
const { sections: staleSections, failed } = await getStaleSections(domain);
|
||||
if (failed) {
|
||||
domainsFailed++;
|
||||
logger.error({ domain, err }, "Failed to check staleness for domain");
|
||||
}
|
||||
for (const section of staleSections) {
|
||||
jobs.push({ domain, section });
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -154,7 +156,7 @@ export async function GET(request: Request) {
|
||||
sectionsStarted++;
|
||||
} catch (err) {
|
||||
// Log but don't fail the cron - other sections may succeed
|
||||
logger.error({ domain, section, err }, "Failed to refresh section");
|
||||
logger.debug({ domain, section, err }, "Section unavailable during cache warm");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -118,7 +118,7 @@ async function fetchProviderData(domain: string): Promise<ProviderData> {
|
||||
|
||||
return { providers };
|
||||
} catch (err) {
|
||||
logger.warn({ err, domain }, "failed to fetch provider data");
|
||||
logger.debug({ err, domain }, "provider data unavailable for OG image");
|
||||
}
|
||||
|
||||
return { providers: [] };
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function POST(
|
||||
]);
|
||||
|
||||
if (!domain) {
|
||||
logger.warn({ domainId }, "screenshot requested for unknown domain");
|
||||
logger.debug({ domainId }, "screenshot requested for unknown domain");
|
||||
return NextResponse.json({ error: "Domain not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function POST(
|
||||
} catch (err) {
|
||||
// Log error but fall back to unblocked to avoid breaking screenshots
|
||||
// for transient database issues. Blocked domains are a soft protection.
|
||||
logger.warn(
|
||||
logger.error(
|
||||
{ err, domain: domain.name },
|
||||
"failed to check block status, defaulting to unblocked",
|
||||
);
|
||||
@@ -189,7 +189,7 @@ export async function GET(
|
||||
// Still running
|
||||
return NextResponse.json({ status: "running" }, { headers: rateLimit.headers });
|
||||
} catch (err) {
|
||||
logger.warn({ err, runId }, "failed to get workflow run status");
|
||||
logger.debug({ err, runId }, "workflow run unavailable");
|
||||
return NextResponse.json({ error: "Run not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,6 @@ const handler = async (req: Request) => {
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => ctx,
|
||||
onError: ({ path, error }) => {
|
||||
void (async () => {
|
||||
const { logger } = await import("@domainstack/logger");
|
||||
logger.error({ err: error, source: "trpc", path });
|
||||
})();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+10
-16
@@ -79,25 +79,14 @@ interface IplocateApiResponse {
|
||||
* Fetch raw GeoIP data from iplocate.io API.
|
||||
* Returns the raw response for caching.
|
||||
*/
|
||||
async function fetchFromApi(ip: string): Promise<IplocateApiResponse> {
|
||||
const apiKey = process.env.IPLOCATE_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
logger.warn("IPLOCATE_API_KEY not configured, skipping IP lookup");
|
||||
throw new Error("IPLOCATE_API_KEY not configured");
|
||||
}
|
||||
|
||||
async function fetchFromApi(ip: string, apiKey: string): Promise<IplocateApiResponse> {
|
||||
const url = new URL(`https://www.iplocate.io/api/lookup/${encodeURIComponent(ip)}`);
|
||||
url.searchParams.set("apikey", apiKey);
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
logger.error(
|
||||
{ status: res.status, body: body.slice(0, 500) },
|
||||
"iplocate.io lookup failed with non-OK status",
|
||||
);
|
||||
await res.body?.cancel();
|
||||
throw new Error(`Upstream error looking up IP metadata: ${res.status}`);
|
||||
}
|
||||
|
||||
@@ -105,7 +94,6 @@ async function fetchFromApi(ip: string): Promise<IplocateApiResponse> {
|
||||
|
||||
// Check for API error response
|
||||
if (data.error) {
|
||||
logger.error({ error: data.error }, "iplocate.io returned error message");
|
||||
throw new Error(`iplocate.io error: ${data.error}`);
|
||||
}
|
||||
|
||||
@@ -133,8 +121,14 @@ async function getOrFetchApiResponse(ip: string): Promise<IplocateApiResponse |
|
||||
}
|
||||
|
||||
// Cache miss or Redis unavailable - fetch from API
|
||||
const apiKey = process.env.IPLOCATE_API_KEY;
|
||||
if (!apiKey) {
|
||||
logger.debug("IPLOCATE_API_KEY not configured, skipping IP lookup");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await fetchFromApi(ip);
|
||||
const raw = await fetchFromApi(ip, apiKey);
|
||||
|
||||
// Store raw response in Redis (fire-and-forget)
|
||||
if (redis) {
|
||||
@@ -145,7 +139,7 @@ async function getOrFetchApiResponse(ip: string): Promise<IplocateApiResponse |
|
||||
|
||||
return raw;
|
||||
} catch (err) {
|
||||
logger.error({ err }, "iplocate.io lookup failed");
|
||||
logger.warn({ err }, "iplocate.io lookup failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ function createPricingProvider(
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
logger.error({ provider: name, status: res.status }, "upstream error");
|
||||
logger.warn({ provider: name, status: res.status }, "upstream error");
|
||||
throw new Error(`${name} API returned ${res.status}`);
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ const dynadotProvider = createPricingProvider(
|
||||
|
||||
// Check for API errors (Dynadot returns 200 OK with error payloads)
|
||||
if (data?.code !== 200) {
|
||||
logger.error(
|
||||
logger.warn(
|
||||
{
|
||||
err: data?.error,
|
||||
provider: "dynadot",
|
||||
|
||||
@@ -181,7 +181,7 @@ export async function checkRateLimit(
|
||||
};
|
||||
} catch (err) {
|
||||
// Redis error - fail open to prevent blocking requests
|
||||
logger.warn({ err }, "rate limit check failed, allowing request");
|
||||
logger.error({ err }, "rate limit check failed, allowing request");
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { RateLimitConfig } from "@domainstack/redis/ratelimit";
|
||||
import { fetchCertificates } from "@domainstack/server/services/certificates";
|
||||
import { fetchDns } from "@domainstack/server/services/dns";
|
||||
import { fetchFavicon } from "@domainstack/server/services/favicon";
|
||||
import { RemoteDataUnavailableError } from "@domainstack/server/services/fetch-errors";
|
||||
import { fetchHeaders, getHttpStatusMessage } from "@domainstack/server/services/headers";
|
||||
import { fetchHosting } from "@domainstack/server/services/hosting";
|
||||
import { fetchRegistration } from "@domainstack/server/services/registration";
|
||||
@@ -36,6 +37,19 @@ const DomainInputSchema = z.object({ domain: z.string().min(1) }).transform(({ d
|
||||
return { domain: registrable };
|
||||
});
|
||||
|
||||
function logFlex(domain: string, section: string, err: unknown, optional = false): void {
|
||||
const fields = { domain, section, err };
|
||||
if (err instanceof RemoteDataUnavailableError) {
|
||||
if (optional) {
|
||||
logger.debug(fields, `${section} unavailable`);
|
||||
} else {
|
||||
logger.warn(fields, `${section} unavailable`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
logger.error(fields, `${section} failed unexpectedly`);
|
||||
}
|
||||
|
||||
export const domainRouter = createTRPCRouter({
|
||||
/**
|
||||
* Get registration data for a domain.
|
||||
@@ -68,7 +82,7 @@ export const domainRouter = createTRPCRouter({
|
||||
}
|
||||
return { success: true, cached: false, data: result.data };
|
||||
} catch (err) {
|
||||
logger.error({ domain: input.domain, err }, "registration fetch failed");
|
||||
logFlex(input.domain, "registration", err);
|
||||
return {
|
||||
success: false,
|
||||
cached: false,
|
||||
@@ -101,7 +115,7 @@ export const domainRouter = createTRPCRouter({
|
||||
const result = await fetchDns(input.domain);
|
||||
return { success: true, cached: false, data: result.data };
|
||||
} catch (err) {
|
||||
logger.error({ domain: input.domain, err }, "dns fetch failed");
|
||||
logFlex(input.domain, "dns", err);
|
||||
return {
|
||||
success: false,
|
||||
cached: false,
|
||||
@@ -134,7 +148,7 @@ export const domainRouter = createTRPCRouter({
|
||||
const result = await fetchHosting(input.domain);
|
||||
return { success: true, cached: false, data: result.data };
|
||||
} catch (err) {
|
||||
logger.error({ domain: input.domain, err }, "hosting fetch failed");
|
||||
logFlex(input.domain, "hosting", err);
|
||||
return {
|
||||
success: false,
|
||||
cached: false,
|
||||
@@ -175,7 +189,7 @@ export const domainRouter = createTRPCRouter({
|
||||
}
|
||||
return { success: true, cached: false, data: result.data };
|
||||
} catch (err) {
|
||||
logger.error({ domain: input.domain, err }, "certificates fetch failed");
|
||||
logFlex(input.domain, "certificates", err);
|
||||
return {
|
||||
success: false,
|
||||
cached: false,
|
||||
@@ -223,7 +237,7 @@ export const domainRouter = createTRPCRouter({
|
||||
}
|
||||
return { success: true, cached: false, data: result.data };
|
||||
} catch (err) {
|
||||
logger.error({ domain: input.domain, err }, "headers fetch failed");
|
||||
logFlex(input.domain, "headers", err);
|
||||
return {
|
||||
success: false,
|
||||
cached: false,
|
||||
@@ -264,7 +278,7 @@ export const domainRouter = createTRPCRouter({
|
||||
}
|
||||
return { success: true, cached: false, data: result.data };
|
||||
} catch (err) {
|
||||
logger.error({ domain: input.domain, err }, "seo fetch failed");
|
||||
logFlex(input.domain, "seo", err);
|
||||
return {
|
||||
success: false,
|
||||
cached: false,
|
||||
@@ -295,7 +309,7 @@ export const domainRouter = createTRPCRouter({
|
||||
const result = await fetchFavicon(input.domain);
|
||||
return { success: true, cached: false, data: result.data };
|
||||
} catch (err) {
|
||||
logger.error({ domain: input.domain, err }, "favicon fetch failed");
|
||||
logFlex(input.domain, "favicon", err, true);
|
||||
return {
|
||||
success: false,
|
||||
cached: false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createTRPCRouter, rateLimit, publicProcedure } from "@/trpc/init";
|
||||
import { getProviderLogo } from "@domainstack/db/queries/provider-logos";
|
||||
import { getProviderById } from "@domainstack/db/queries/providers";
|
||||
import { createLogger } from "@domainstack/logger";
|
||||
import { RemoteDataUnavailableError } from "@domainstack/server/services/fetch-errors";
|
||||
import { fetchProviderLogo } from "@domainstack/server/services/provider-logo";
|
||||
|
||||
const logger = createLogger({ source: "routers/provider" });
|
||||
@@ -36,7 +37,11 @@ export const providerRouter = createTRPCRouter({
|
||||
const result = await fetchProviderLogo(input.providerId, providerDomain);
|
||||
return { success: true, cached: false, data: result.data };
|
||||
} catch (err) {
|
||||
logger.error({ providerId: input.providerId, err }, "provider logo fetch failed");
|
||||
if (err instanceof RemoteDataUnavailableError) {
|
||||
logger.debug({ providerId: input.providerId, err }, "provider logo unavailable");
|
||||
} else {
|
||||
logger.error({ providerId: input.providerId, err }, "provider logo failed unexpectedly");
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
cached: false,
|
||||
|
||||
@@ -599,10 +599,10 @@ export const trackingRouter = createTRPCRouter({
|
||||
);
|
||||
|
||||
if (error) {
|
||||
logger.error({ err: error, trackedDomainId });
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to send email",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -614,12 +614,11 @@ export const trackingRouter = createTRPCRouter({
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error({ err: error, trackedDomainId });
|
||||
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to send email",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type DomainToolProcedure,
|
||||
type DomainToolResult,
|
||||
} from "@/lib/chat/domain-tools";
|
||||
import { getTrpcErrorCode, isExpectedTrpcError } from "@/lib/trpc/errors";
|
||||
import { isExpectedTrpcError } from "@/lib/trpc/errors";
|
||||
|
||||
export interface ToolContext {
|
||||
ip: string | null;
|
||||
@@ -48,17 +48,13 @@ async function domainLookupStep(procedure: DomainToolProcedure, domain: string,
|
||||
}
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
const { createLogger } = await import("@domainstack/logger");
|
||||
const logger = createLogger({ source: "chat/tools" });
|
||||
// Domain lookups return `{ success: false }` instead of throwing.
|
||||
// Throws here are tRPC validation/rate-limit errors, or unexpected bugs.
|
||||
const trpcCode = getTrpcErrorCode(err);
|
||||
if (isExpectedTrpcError(err)) {
|
||||
logger.warn({ err, domain, procedure, code: trpcCode }, "tool step failed (expected)");
|
||||
return { error: getDomainToolErrorMessage(err) };
|
||||
}
|
||||
logger.error({ err, domain, procedure }, "tool step failed (unexpected)");
|
||||
throw new RetryableError(`domain tool ${procedure} failed`, { retryAfter: "5s" });
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw new RetryableError(`domain tool ${procedure} failed: ${reason}`, { retryAfter: "5s" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ async function captureScreenshot(domain: string): Promise<CaptureResult> {
|
||||
imageBuffer: result.imageBase64,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn({ err, domain }, "screenshot capture failed, caching miss");
|
||||
logger.debug({ err, domain }, "screenshot unavailable, caching miss");
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export const withDomainAccessUpdate = t.middleware(async ({ input, next, getRawI
|
||||
try {
|
||||
const updated = await updateLastAccessed(domain);
|
||||
if (!updated) {
|
||||
logger.error({ domain }, "failed to record domain access");
|
||||
logger.debug({ domain }, "domain access record not found");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logger.error({ err, domain }, "failed to record domain access");
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const log = vi.hoisted(() => ({
|
||||
error: vi.fn<(...args: unknown[]) => void>(),
|
||||
info: vi.fn<(...args: unknown[]) => void>(),
|
||||
warn: vi.fn<(...args: unknown[]) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("@domainstack/logger", () => ({
|
||||
createLogger: () => log,
|
||||
}));
|
||||
|
||||
const { publicProcedure } = await import("../procedures");
|
||||
const { t } = await import("../trpc");
|
||||
|
||||
const router = t.router({
|
||||
expectedFailure: publicProcedure.query(() => {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "missing" });
|
||||
}),
|
||||
unexpectedFailure: publicProcedure.query(() => {
|
||||
throw new Error("broken");
|
||||
}),
|
||||
success: publicProcedure.query(() => "ok"),
|
||||
});
|
||||
|
||||
const caller = router.createCaller({ ip: null, req: undefined, session: null });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("withLogging", () => {
|
||||
it("logs successful procedures at info", async () => {
|
||||
await expect(caller.success()).resolves.toBe("ok");
|
||||
|
||||
expect(log.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ outcome: "ok", path: "success" }),
|
||||
"procedure completed",
|
||||
);
|
||||
expect(log.warn).not.toHaveBeenCalled();
|
||||
expect(log.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs expected caller errors at info", async () => {
|
||||
await expect(caller.expectedFailure()).rejects.toThrow("missing");
|
||||
|
||||
expect(log.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ code: "NOT_FOUND", outcome: "error", path: "expectedFailure" }),
|
||||
"procedure completed",
|
||||
);
|
||||
expect(log.warn).not.toHaveBeenCalled();
|
||||
expect(log.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs unexpected server errors at error", async () => {
|
||||
await expect(caller.unexpectedFailure()).rejects.toThrow("broken");
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
outcome: "error",
|
||||
path: "unexpectedFailure",
|
||||
}),
|
||||
"procedure completed",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,24 @@ import { t } from "../trpc";
|
||||
|
||||
const logger = createLogger({ source: "trpc" });
|
||||
|
||||
const EXPECTED_ERROR_CODES = new Set([
|
||||
"PARSE_ERROR",
|
||||
"BAD_REQUEST",
|
||||
"UNAUTHORIZED",
|
||||
"PAYMENT_REQUIRED",
|
||||
"FORBIDDEN",
|
||||
"NOT_FOUND",
|
||||
"METHOD_NOT_SUPPORTED",
|
||||
"CONFLICT",
|
||||
"PRECONDITION_FAILED",
|
||||
"PAYLOAD_TOO_LARGE",
|
||||
"UNSUPPORTED_MEDIA_TYPE",
|
||||
"UNPROCESSABLE_CONTENT",
|
||||
"PRECONDITION_REQUIRED",
|
||||
"TOO_MANY_REQUESTS",
|
||||
"CLIENT_CLOSED_REQUEST",
|
||||
]);
|
||||
|
||||
/**
|
||||
* One canonical log line per procedure: path, type, duration, outcome,
|
||||
* and posthogDistinctId when the caller is authenticated.
|
||||
@@ -25,8 +43,10 @@ export const withLogging = t.middleware(async ({ path, type, ctx, next }) => {
|
||||
|
||||
if (result.ok) {
|
||||
logger.info(fields, "procedure completed");
|
||||
} else if (EXPECTED_ERROR_CODES.has(result.error.code)) {
|
||||
logger.info({ ...fields, code: result.error.code, err: result.error }, "procedure completed");
|
||||
} else {
|
||||
logger.error({ ...fields, err: result.error }, "procedure completed");
|
||||
logger.error({ ...fields, code: result.error.code, err: result.error }, "procedure completed");
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -22,7 +22,7 @@ export const analytics = {
|
||||
properties,
|
||||
}),
|
||||
}).catch((err) => {
|
||||
console.error("failed to track event", err);
|
||||
console.debug("failed to track event", err);
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -11,22 +11,17 @@ const logger = createLogger({ source: "blob/vercel" });
|
||||
*/
|
||||
export class VercelBlobProvider implements BlobProvider {
|
||||
async put(options: PutBlobOptions): Promise<PutBlobResult> {
|
||||
try {
|
||||
const blob = await put(options.pathname, options.body, {
|
||||
access: "public",
|
||||
contentType: options.contentType,
|
||||
cacheControlMaxAge: options.cacheControlMaxAge,
|
||||
allowOverwrite: true,
|
||||
});
|
||||
const blob = await put(options.pathname, options.body, {
|
||||
access: "public",
|
||||
contentType: options.contentType,
|
||||
cacheControlMaxAge: options.cacheControlMaxAge,
|
||||
allowOverwrite: true,
|
||||
});
|
||||
|
||||
return {
|
||||
url: blob.url,
|
||||
pathname: options.pathname,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error({ err, pathname: options.pathname }, "Failed to upload blob");
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
url: blob.url,
|
||||
pathname: options.pathname,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(urls: string[]): Promise<DeleteBlobResult[]> {
|
||||
|
||||
@@ -77,11 +77,9 @@ async function uploadWithRetry(
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
logger.warn({ err, pathname, attempt: attempt + 1, maxAttempts });
|
||||
|
||||
if (attempt < maxAttempts - 1) {
|
||||
const delay = backoffDelayMs(attempt, UPLOAD_BACKOFF_BASE_MS, UPLOAD_BACKOFF_MAX_MS);
|
||||
logger.warn({ err, pathname, retryDelay: delay });
|
||||
logger.warn({ err, pathname, attempt: attempt + 1, maxAttempts, retryDelay: delay });
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export const getDefaultSuggestions = cache(async (): Promise<string[]> => {
|
||||
const suggestions = await edgeConfig.get<string[]>("domain_suggestions");
|
||||
return suggestions ?? [];
|
||||
} catch (err) {
|
||||
logger.error(err, "failed to fetch domain suggestions");
|
||||
logger.warn(err, "failed to fetch domain suggestions");
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -72,7 +72,7 @@ export const getProviderCatalog = cache(async (): Promise<ProviderCatalog | null
|
||||
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
logger.error(err, "failed to fetch provider catalog");
|
||||
logger.warn(err, "failed to fetch provider catalog");
|
||||
return null;
|
||||
}
|
||||
});
|
||||
@@ -96,7 +96,7 @@ export async function getBlocklistSources(): Promise<string[]> {
|
||||
const sources = await get<string[]>("screenshot_blocklist_sources");
|
||||
return sources ?? [];
|
||||
} catch (err) {
|
||||
logger.error(err, "failed to fetch screenshot blocklist sources");
|
||||
logger.warn(err, "failed to fetch screenshot blocklist sources");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export async function getAiChatModel(): Promise<string | null> {
|
||||
const model = await get<string>("ai_chat_model");
|
||||
return model ?? null;
|
||||
} catch (err) {
|
||||
logger.error(err, "failed to fetch AI chat model");
|
||||
logger.warn(err, "failed to fetch AI chat model");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const analytics = {
|
||||
properties,
|
||||
}),
|
||||
}).catch((err) => {
|
||||
console.error("failed to track event", err);
|
||||
console.debug("failed to track event", err);
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ function getProductIds(): { monthlyId?: string; yearlyId?: string } {
|
||||
const monthlyId = process.env.NEXT_PUBLIC_POLAR_MONTHLY_PRODUCT_ID;
|
||||
const yearlyId = process.env.NEXT_PUBLIC_POLAR_YEARLY_PRODUCT_ID;
|
||||
|
||||
if ((!monthlyId || !yearlyId) && !missingProductIdsWarned) {
|
||||
if ((!monthlyId || !yearlyId) && !missingProductIdsWarned && process.env.NODE_ENV !== "test") {
|
||||
missingProductIdsWarned = true;
|
||||
// `products.ts` is imported from client components (checkout UI). Do not
|
||||
// pull `@domainstack/logger` / pino-pretty into the browser bundle.
|
||||
|
||||
@@ -113,7 +113,7 @@ export async function closeBrowser(): Promise<void> {
|
||||
const browser = await browserPromise;
|
||||
await browser.close();
|
||||
} catch (err) {
|
||||
logger.error(err, "failed to close browser");
|
||||
logger.warn(err, "failed to close browser");
|
||||
} finally {
|
||||
browserPromise = null;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { createLogger } from "@domainstack/logger";
|
||||
|
||||
import { type Browser, getBrowser, type Page } from "./browser";
|
||||
import { createPage } from "./page";
|
||||
|
||||
const logger = createLogger({ source: "screenshot/capture" });
|
||||
|
||||
const DEFAULT_VIEWPORT_WIDTH = 1200;
|
||||
const DEFAULT_VIEWPORT_HEIGHT = 630;
|
||||
|
||||
@@ -80,9 +76,6 @@ export async function captureScreenshot(
|
||||
width: actualWidth,
|
||||
height: actualHeight,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(err, "screenshot capture failed");
|
||||
throw err;
|
||||
} finally {
|
||||
// Close page in background to avoid blocking
|
||||
void page?.close();
|
||||
|
||||
@@ -87,7 +87,6 @@ export async function createPage(
|
||||
if (page) {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
logger.warn(err, "failed to create page");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { detectCertificateAuthority, getProvidersFromCatalog } from "@domainstac
|
||||
|
||||
import { fetchCertificateChain, type RawCertificate, type TlsFetchSuccess } from "../tls";
|
||||
import { ttlForCertificates } from "../ttl";
|
||||
import { RemoteDataUnavailableError } from "./fetch-errors";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -91,7 +92,7 @@ async function fetchCertificateChainInternal(domain: string): Promise<FetchResul
|
||||
if (!result.success) {
|
||||
// Transient failures - throw for TanStack Query to retry
|
||||
if (result.error === "fetch_error" || result.error === "timeout") {
|
||||
throw new Error("Certificate fetch failed");
|
||||
throw new RemoteDataUnavailableError("Certificate data unavailable");
|
||||
}
|
||||
|
||||
// Permanent failures (dns_error, tls_error) - return error result
|
||||
|
||||
@@ -10,7 +10,8 @@ import { replaceDns } from "@domainstack/db/queries/dns";
|
||||
import { ensureDomainRecord } from "@domainstack/db/queries/domains";
|
||||
import type { DnsRecordType, DnsRecordsResponse } from "@domainstack/types";
|
||||
|
||||
import { type DnsFetchData, fetchDnsRecords } from "../dns";
|
||||
import { DnsProviderError, type DnsFetchData, fetchDnsRecords } from "../dns";
|
||||
import { RemoteDataUnavailableError } from "./fetch-errors";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -32,7 +33,15 @@ export type DnsResult = { success: true; data: DnsRecordsResponse };
|
||||
*/
|
||||
export async function fetchDns(domain: string): Promise<DnsResult> {
|
||||
// 1. Fetch from DoH providers (throws DnsProviderError on failure)
|
||||
const fetchData = await fetchDnsRecords(domain);
|
||||
let fetchData: DnsFetchData;
|
||||
try {
|
||||
fetchData = await fetchDnsRecords(domain);
|
||||
} catch (err) {
|
||||
if (err instanceof DnsProviderError) {
|
||||
throw new RemoteDataUnavailableError("DNS data unavailable", { cause: err });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 2. Persist to database
|
||||
await persistDnsRecords(domain, fetchData);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { safeFetch } from "@domainstack/safe-fetch";
|
||||
import type { FaviconResponse } from "@domainstack/types";
|
||||
|
||||
import { ttlForFavicon } from "../ttl";
|
||||
import { isDefinitiveNotFoundError } from "./fetch-errors";
|
||||
import { isDefinitiveNotFoundError, RemoteDataUnavailableError } from "./fetch-errors";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -70,7 +70,7 @@ export async function fetchFavicon(domain: string): Promise<FaviconResult> {
|
||||
// If at least one source failed with a transient error (not 404/400),
|
||||
// throw so TanStack Query can retry instead of caching failure
|
||||
if (!fetchResult.allNotFound) {
|
||||
throw new Error(`Favicon fetch failed for ${domain} (transient)`);
|
||||
throw new RemoteDataUnavailableError(`Favicon unavailable for ${domain}`);
|
||||
}
|
||||
|
||||
// Persist "no favicon found" as a cached state (all sources returned 404)
|
||||
|
||||
@@ -4,6 +4,15 @@ import {
|
||||
type SafeFetchErrorCode,
|
||||
} from "@domainstack/safe-fetch";
|
||||
|
||||
/**
|
||||
* A remote domain or provider could not supply data, but the application is
|
||||
* otherwise healthy. Routers use this boundary to keep target-specific
|
||||
* failures separate from persistence, parsing, and other internal failures.
|
||||
*/
|
||||
export class RemoteDataUnavailableError extends Error {
|
||||
readonly name = "RemoteDataUnavailableError";
|
||||
}
|
||||
|
||||
/**
|
||||
* Failures that mean "this URL will never serve us an asset", as opposed to
|
||||
* "the attempt failed this time". Retrying any of these produces the same
|
||||
|
||||
@@ -10,8 +10,14 @@ import { ensureDomainRecord } from "@domainstack/db/queries/domains";
|
||||
import { replaceHeaders } from "@domainstack/db/queries/headers";
|
||||
import type { HeadersResponse } from "@domainstack/types";
|
||||
|
||||
import { fetchHttpHeaders, type HeadersError, type HeadersFetchData } from "../headers";
|
||||
import {
|
||||
fetchHttpHeaders,
|
||||
HeadersFetchError,
|
||||
type HeadersError,
|
||||
type HeadersFetchData,
|
||||
} from "../headers";
|
||||
import { ttlForHeaders } from "../ttl";
|
||||
import { RemoteDataUnavailableError } from "./fetch-errors";
|
||||
|
||||
export { getHttpStatusMessage } from "../headers";
|
||||
|
||||
@@ -39,7 +45,15 @@ export type HeadersResult =
|
||||
*/
|
||||
export async function fetchHeaders(domain: string): Promise<HeadersResult> {
|
||||
// 1. Fetch headers from domain (throws HeadersFetchError on transient failure)
|
||||
const fetchResult = await fetchHttpHeaders(domain);
|
||||
let fetchResult;
|
||||
try {
|
||||
fetchResult = await fetchHttpHeaders(domain);
|
||||
} catch (err) {
|
||||
if (err instanceof HeadersFetchError) {
|
||||
throw new RemoteDataUnavailableError("HTTP headers unavailable", { cause: err });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!fetchResult.success) {
|
||||
return { success: false, error: fetchResult.error };
|
||||
|
||||
@@ -161,7 +161,7 @@ async function lookupGeoIp(ip: string): Promise<GeoIpData | null> {
|
||||
const apiKey = process.env.IPLOCATE_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
logger.warn("IPLOCATE_API_KEY not configured, skipping IP lookup");
|
||||
logger.debug("IPLOCATE_API_KEY not configured, skipping IP lookup");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ async function lookupGeoIp(ip: string): Promise<GeoIpData | null> {
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
logger.error(
|
||||
logger.warn(
|
||||
{ status: res.status, body: body.slice(0, 500) },
|
||||
"iplocate.io lookup failed with non-OK status",
|
||||
);
|
||||
@@ -191,7 +191,7 @@ async function lookupGeoIp(ip: string): Promise<GeoIpData | null> {
|
||||
const data = (await res.json()) as IplocateApiResponse;
|
||||
|
||||
if (data.error) {
|
||||
logger.error({ error: data.error }, "iplocate.io returned error message");
|
||||
logger.warn({ error: data.error }, "iplocate.io returned error message");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ async function lookupGeoIp(ip: string): Promise<GeoIpData | null> {
|
||||
|
||||
return transformApiResponse(data);
|
||||
} catch (err) {
|
||||
logger.error({ err }, "iplocate.io lookup failed");
|
||||
logger.warn({ err }, "iplocate.io lookup failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { safeFetch } from "@domainstack/safe-fetch";
|
||||
import type { ProviderLogoResponse } from "@domainstack/types";
|
||||
|
||||
import { ttlForProviderIcon } from "../ttl";
|
||||
import { isDefinitiveNotFoundError } from "./fetch-errors";
|
||||
import { isDefinitiveNotFoundError, RemoteDataUnavailableError } from "./fetch-errors";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -70,7 +70,7 @@ export async function fetchProviderLogo(
|
||||
// If at least one source failed with a transient error (not 404/400),
|
||||
// throw so TanStack Query can retry instead of caching failure
|
||||
if (!fetchResult.allNotFound) {
|
||||
throw new Error(`Provider logo fetch failed for ${providerDomain} (transient)`);
|
||||
throw new RemoteDataUnavailableError(`Provider logo unavailable for ${providerDomain}`);
|
||||
}
|
||||
|
||||
// Persist "no logo found" as a cached state (all sources returned 404)
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
|
||||
import { ttlForRegistration } from "../ttl";
|
||||
import { lookupWhois as lookup } from "../whois";
|
||||
import { RemoteDataUnavailableError } from "./fetch-errors";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -85,7 +86,7 @@ async function lookupWhois(domain: string): Promise<LookupResult> {
|
||||
if (!result.success) {
|
||||
// Transient errors throw - let TanStack Query retry
|
||||
if (result.error === "retry" || result.error === "timeout") {
|
||||
throw new Error(`WHOIS lookup failed: ${result.error}`);
|
||||
throw new RemoteDataUnavailableError(`WHOIS lookup failed: ${result.error}`);
|
||||
}
|
||||
// Permanent errors return as result
|
||||
return { success: false, error: result.error };
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
import { parseHtmlMeta, parseRobotsTxt, selectPreview } from "../seo";
|
||||
import { isExpectedTlsError } from "../tls";
|
||||
import { ttlForSeo } from "../ttl";
|
||||
import { RemoteDataUnavailableError } from "./fetch-errors";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -214,7 +215,7 @@ async function fetchHtml(domain: string): Promise<HtmlFetchData> {
|
||||
}
|
||||
|
||||
// Transient failure - throw for TanStack Query to retry
|
||||
throw new Error("HTML fetch failed", { cause: err });
|
||||
throw new RemoteDataUnavailableError("HTML data unavailable", { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user