logging: complete phase 5 contract and enforce via eslint rules

This commit is contained in:
Richard R 2026-05-27 04:54:52 -06:00
parent 29cc4fa42d
commit 034c1f03a4
45 changed files with 666 additions and 183 deletions

View file

@ -36,6 +36,39 @@ const eslintConfig = [
files: ["src/app/api/**/*.ts", "src/lib/server/**/*.ts"],
rules: {
"no-console": "error",
"no-restricted-syntax": [
"error",
{
selector:
":matches(CallExpression[callee.property.name=/^(trace|debug|info|warn|error|fatal)$/][callee.object.name=/^(logger|serverLogger)$/],CallExpression[callee.property.name=/^(trace|debug|info|warn|error|fatal)$/][callee.object.property.name='logger'])[arguments.length<2]",
message:
"Server logger calls must pass context + message: logger.<level>({ event, ...ctx }, 'message').",
},
{
selector:
":matches(CallExpression[callee.property.name=/^(trace|debug|info|warn|error|fatal)$/][callee.object.name=/^(logger|serverLogger)$/],CallExpression[callee.property.name=/^(trace|debug|info|warn|error|fatal)$/][callee.object.property.name='logger'])[arguments.0.type='Literal']",
message:
"Server logger first argument must be an object with an event field, not a string literal.",
},
{
selector:
":matches(CallExpression[callee.property.name=/^(trace|debug|info|warn|error|fatal)$/][callee.object.name=/^(logger|serverLogger)$/],CallExpression[callee.property.name=/^(trace|debug|info|warn|error|fatal)$/][callee.object.property.name='logger'])[arguments.0.type='TemplateLiteral']",
message:
"Server logger first argument must be an object with an event field, not a template string.",
},
{
selector:
":matches(CallExpression[callee.property.name=/^(trace|debug|info|warn|error|fatal)$/][callee.object.name=/^(logger|serverLogger)$/],CallExpression[callee.property.name=/^(trace|debug|info|warn|error|fatal)$/][callee.object.property.name='logger'])[arguments.0.type='ObjectExpression']:not(:has(Property[key.name='event']))",
message:
"Server logger context object must include an event field.",
},
{
selector:
":matches(CallExpression[callee.property.name=/^(trace|debug|info|warn|error|fatal)$/][callee.object.name=/^(logger|serverLogger)$/],CallExpression[callee.property.name=/^(trace|debug|info|warn|error|fatal)$/][callee.object.property.name='logger']) > ObjectExpression:first-child > Property[key.name='err']",
message:
"Use `error` (typically from errorToLog(...)) instead of `err` in server logs.",
},
],
},
},
];

View file

@ -4,7 +4,7 @@ import { auth } from '@/lib/server/auth/auth';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { deleteUserStorageData } from '@/lib/server/user/data-cleanup';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger';
export async function DELETE() {
if (!isAuthEnabled() || !auth) {
@ -29,7 +29,13 @@ export async function DELETE() {
try {
await deleteUserStorageData(session.user.id, testNamespace);
} catch (error) {
serverLogger.warn({ err: error }, '[account-delete] Failed to clean up namespaced user storage before deletion:');
serverLogger.warn({
event: 'account.delete.storage_cleanup_failed',
degraded: true,
step: 'namespaced_storage_cleanup',
userIdHash: hashForLog(session.user.id),
error: errorToLog(error),
}, 'Failed to clean up namespaced user storage before deletion');
}
}
@ -41,7 +47,10 @@ export async function DELETE() {
return NextResponse.json({ success: true });
} catch (error) {
serverLogger.error({ err: error }, 'Failed to delete account:');
serverLogger.error({
event: 'account.delete.failed',
error: errorToLog(error),
}, 'Failed to delete account');
return NextResponse.json(
{ error: 'Failed to delete account' },
{ status: 500 }

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminContext } from '@/lib/server/auth/admin';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import {
AdminProviderError,
deleteAdminProvider,
@ -42,7 +42,11 @@ export async function PUT(
if (error instanceof AdminProviderError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
serverLogger.error({ err: error }, '[admin/providers/:id] update failed:');
serverLogger.error({
event: 'admin.providers.update.failed',
providerId: id,
error: errorToLog(error),
}, 'Admin provider update failed');
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}
@ -62,7 +66,11 @@ export async function DELETE(
if (error instanceof AdminProviderError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
serverLogger.error({ err: error }, '[admin/providers/:id] delete failed:');
serverLogger.error({
event: 'admin.providers.delete.failed',
providerId: id,
error: errorToLog(error),
}, 'Admin provider delete failed');
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminContext } from '@/lib/server/auth/admin';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import {
AdminProviderError,
createAdminProvider,
@ -47,7 +47,10 @@ export async function POST(req: NextRequest) {
if (error instanceof AdminProviderError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
serverLogger.error({ err: error }, '[admin/providers] create failed:');
serverLogger.error({
event: 'admin.providers.create.failed',
error: errorToLog(error),
}, 'Admin provider create failed');
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}

View file

@ -11,7 +11,7 @@ import { requireAuthContext } from '@/lib/server/auth/auth';
import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import {
deleteAudiobookObject,
getAudiobookObjectBuffer,
@ -186,7 +186,12 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
}
ffmpeg.stderr.on('data', (data) => {
serverLogger.warn(`ffmpeg stderr: ${data}`);
serverLogger.warn({
event: 'audiobook.chapter.ffmpeg.stderr',
degraded: true,
step: 'ffmpeg',
stderr: String(data),
}, 'ffmpeg stderr');
});
ffmpeg.on('close', (code) => {
@ -326,7 +331,11 @@ export async function POST(request: NextRequest) {
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
});
if (!existingResult.settings) {
serverLogger.error({ bookId, storageUserId }, 'Invalid audiobook.meta.json settings payload');
serverLogger.error({
event: 'audiobook.chapter.meta_settings.invalid',
bookId,
storageUserId,
}, 'Invalid audiobook.meta.json settings payload');
return NextResponse.json({ error: 'Invalid audiobook metadata settings' }, { status: 500 });
}
normalizedExistingSettings = normalizeNativeSpeedForSettings(existingResult.settings);
@ -401,6 +410,9 @@ export async function POST(request: NextRequest) {
);
} catch (error) {
serverLogger.warn({
event: 'audiobook.chapter.meta_settings.persist_migration_failed',
degraded: true,
step: 'persist_migrated_settings',
bookId,
storageUserId,
error: error instanceof Error ? error.message : String(error),
@ -609,7 +621,12 @@ export async function POST(request: NextRequest) {
request.signal,
);
} catch (copyError) {
serverLogger.warn({ err: copyError }, 'Chapter remux failed; falling back to mp3 re-encode:');
serverLogger.warn({
event: 'audiobook.chapter.remux.failed',
degraded: true,
fallbackPath: 'mp3_reencode',
error: errorToLog(copyError),
}, 'Chapter remux failed; falling back to mp3 re-encode');
await runFFmpeg(
chapterEncodeArgs(inputPath, chapterOutputTempPath, format, postSpeed, titleTag),
request.signal,
@ -729,7 +746,10 @@ export async function POST(request: NextRequest) {
return response;
}
serverLogger.error({ err: error }, 'Error processing audio chapter:');
serverLogger.error({
event: 'audiobook.chapter.process.failed',
error: errorToLog(error),
}, 'Failed to process audio chapter');
const response = NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 });
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
@ -824,7 +844,10 @@ export async function GET(request: NextRequest) {
},
});
} catch (error) {
serverLogger.error({ err: error }, 'Error downloading chapter:');
serverLogger.error({
event: 'audiobook.chapter.download.failed',
error: errorToLog(error),
}, 'Failed to download chapter');
return NextResponse.json({ error: 'Failed to download chapter' }, { status: 500 });
}
}
@ -892,7 +915,10 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ success: true });
} catch (error) {
serverLogger.error({ err: error }, 'Error deleting chapter:');
serverLogger.error({
event: 'audiobook.chapter.delete.failed',
error: errorToLog(error),
}, 'Failed to delete chapter');
return NextResponse.json({ error: 'Failed to delete chapter' }, { status: 500 });
}
}

View file

@ -7,7 +7,7 @@ import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { audiobooks, audiobookChapters } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import {
audiobookPrefix,
deleteAudiobookObject,
@ -116,7 +116,12 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
}
ffmpeg.stderr.on('data', (data) => {
serverLogger.warn(`ffmpeg stderr: ${data}`);
serverLogger.warn({
event: 'audiobook.ffmpeg.stderr',
degraded: true,
step: 'ffmpeg',
stderr: String(data),
}, 'ffmpeg stderr');
});
ffmpeg.on('close', (code) => {
@ -283,7 +288,12 @@ export async function GET(request: NextRequest) {
request.signal,
);
} catch (copyError) {
serverLogger.warn({ err: copyError }, 'MP3 concat copy failed; falling back to re-encode:');
serverLogger.warn({
event: 'audiobook.concat_copy.mp3.failed',
degraded: true,
fallbackPath: 'reencode',
error: errorToLog(copyError),
}, 'MP3 concat copy failed; falling back to re-encode');
await runFFmpeg(
['-f', 'concat', '-safe', '0', '-i', listPath, '-c:a', 'libmp3lame', '-b:a', '64k', outputPath],
request.signal,
@ -312,7 +322,12 @@ export async function GET(request: NextRequest) {
request.signal,
);
} catch (copyError) {
serverLogger.warn({ err: copyError }, 'M4B concat copy failed; falling back to re-encode:');
serverLogger.warn({
event: 'audiobook.concat_copy.m4b.failed',
degraded: true,
fallbackPath: 'reencode',
error: errorToLog(copyError),
}, 'M4B concat copy failed; falling back to re-encode');
await runFFmpeg(
[
'-f',
@ -361,7 +376,10 @@ export async function GET(request: NextRequest) {
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
return NextResponse.json({ error: 'cancelled' }, { status: 499 });
}
serverLogger.error({ err: error }, 'Error creating full audiobook:');
serverLogger.error({
event: 'audiobook.create.failed',
error: errorToLog(error),
}, 'Failed to create full audiobook');
return NextResponse.json({ error: 'Failed to create full audiobook file' }, { status: 500 });
} finally {
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {});
@ -409,7 +427,10 @@ export async function DELETE(request: NextRequest) {
const deleted = await deleteAudiobookPrefix(audiobookPrefix(bookId, storageUserId, testNamespace)).catch(() => 0);
return NextResponse.json({ success: true, existed: deleted > 0 });
} catch (error) {
serverLogger.error({ err: error }, 'Error resetting audiobook:');
serverLogger.error({
event: 'audiobook.reset.failed',
error: errorToLog(error),
}, 'Failed to reset audiobook');
return NextResponse.json({ error: 'Failed to reset audiobook' }, { status: 500 });
}
}

View file

@ -11,7 +11,7 @@ import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/li
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope';
import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
@ -156,7 +156,10 @@ export async function GET(request: NextRequest) {
settings,
});
} catch (error) {
serverLogger.error({ err: error }, 'Error fetching chapters:');
serverLogger.error({
event: 'audiobook.status.fetch.failed',
error: errorToLog(error),
}, 'Failed to fetch audiobook chapters');
return NextResponse.json({ error: 'Failed to fetch chapters' }, { status: 500 });
}
}

View file

@ -273,7 +273,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
storageUserIdHash,
requestId,
error: error instanceof Error ? error.message : String(error),
});
}, 'SSE DB resync failed');
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`));
});
}, SSE_RESYNC_INTERVAL_MS);
@ -306,7 +306,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
storageUserIdHash,
parseStatus: current.parseStatus,
requestedOpId,
});
}, 'Parse stream running without opId and non-terminal status');
}
} else if (loggedMissingOpId) {
loggedMissingOpId = false;
@ -346,7 +346,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
opId: currentOpId,
status: response.status,
detail,
});
}, 'Worker stream request failed');
await sleep(500);
continue;
}
@ -355,7 +355,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
event: 'documents.parsed.events.worker_stream_missing_body',
documentId: id,
opId: currentOpId,
});
}, 'Worker stream response missing body');
await sleep(500);
continue;
}
@ -456,7 +456,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
event: 'documents.parsed.events.worker_proxy_crashed',
documentId: id,
error: errorToLog(error),
});
}, 'Worker proxy crashed while streaming parse events');
if (!closed) {
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`));
}
@ -486,7 +486,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
logger.error({
event: 'documents.parsed.events.route_failed',
error: errorToLog(error),
});
}, 'Parsed events route failed');
return NextResponse.json({ error: 'Failed to stream parsed PDF progress' }, { status: 500 });
}
}

View file

@ -166,7 +166,7 @@ async function finalizeFromWorkerState(input: {
documentId: input.row.id,
userIdHash: hashForLog(input.row.userId),
parsedJsonKey,
});
}, 'Worker output parsed successfully but contained no blocks');
}
return new NextResponse(new Uint8Array(json), {
@ -223,7 +223,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
documentId: id,
userIdHash: hashForLog(row.userId),
opId: requestedOpId,
});
}, 'Requested worker operation id was unavailable');
}
let state = parseDocumentParseState(row.parseState);
@ -288,7 +288,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
documentId: id,
userIdHash: hashForLog(row.userId),
parsedJsonKey: row.parsedJsonKey,
});
}, 'Parsed document blob contained no blocks');
}
return new NextResponse(new Uint8Array(json), {
@ -308,7 +308,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
logger.error({
event: 'documents.parsed.get_failed',
error: errorToLog(error),
});
}, 'Failed to read parsed PDF');
return NextResponse.json({ error: 'Failed to read parsed PDF' }, { status: 500 });
}
}
@ -388,7 +388,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
logger.error({
event: 'documents.parsed.force_refresh_failed',
error: errorToLog(error),
});
}, 'Failed to force PDF refresh');
return NextResponse.json({ error: 'Failed to force PDF refresh' }, { status: 500 });
}
}

View file

@ -7,7 +7,7 @@ import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/li
import { mergeDocumentSettings } from '@/lib/shared/document-settings';
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
@ -93,7 +93,10 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
hasStoredSettings: Boolean(row),
});
} catch (error) {
serverLogger.error({ err: error }, 'Error loading document settings:');
serverLogger.error({
event: 'documents.settings.load.failed',
error: errorToLog(error),
}, 'Failed to load document settings');
return NextResponse.json({ error: 'Failed to load document settings' }, { status: 500 });
}
}
@ -162,7 +165,10 @@ export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string
applied: true,
});
} catch (error) {
serverLogger.error({ err: error }, 'Error updating document settings:');
serverLogger.error({
event: 'documents.settings.update.failed',
error: errorToLog(error),
}, 'Failed to update document settings');
return NextResponse.json({ error: 'Failed to update document settings' }, { status: 500 });
}
}

View file

@ -7,7 +7,7 @@ import { contentTypeForName } from '@/lib/server/storage/library-mount';
import { getDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
@ -45,7 +45,12 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
serverLogger.info({ id }, '[blob-fallback] download proxy used');
serverLogger.info({
event: 'documents.blob.get.fallback.proxy_used',
degraded: true,
fallbackPath: 'download_proxy',
documentId: id,
}, 'Document download fallback proxy used');
const rows = (await db
.select({ id: documents.id, userId: documents.userId, name: documents.name })
@ -82,7 +87,10 @@ export async function GET(req: NextRequest) {
throw error;
}
} catch (error) {
serverLogger.error({ err: error }, 'Error loading document content fallback:');
serverLogger.error({
event: 'documents.blob.get.fallback.failed',
error: errorToLog(error),
}, 'Failed to load document content fallback');
return NextResponse.json({ error: 'Failed to load document content' }, { status: 500 });
}
}

View file

@ -6,7 +6,7 @@ import { requireAuthContext } from '@/lib/server/auth/auth';
import { isValidDocumentId, presignGet } from '@/lib/server/documents/blobstore';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
@ -51,7 +51,12 @@ export async function GET(req: NextRequest) {
const fallbackUrl = `/api/documents/blob/get/fallback?id=${encodeURIComponent(doc.id)}`;
const directUrl = await presignGet(doc.id, testNamespace).catch(() => null);
if (!directUrl) {
serverLogger.warn({ id: doc.id }, '[blob-fallback] presign download unavailable, redirecting to proxy fallback');
serverLogger.warn({
event: 'documents.blob.get.presign.unavailable',
degraded: true,
fallbackPath: 'download_proxy',
documentId: doc.id,
}, 'Presigned document download unavailable, redirecting to proxy fallback');
return NextResponse.redirect(fallbackUrl, {
status: 307,
headers: { 'Cache-Control': 'no-store' },
@ -63,7 +68,10 @@ export async function GET(req: NextRequest) {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
serverLogger.error({ err: error }, 'Error creating document download signature:');
serverLogger.error({
event: 'documents.blob.get.presign.failed',
error: errorToLog(error),
}, 'Failed to create document download signature');
return NextResponse.json({ error: 'Failed to prepare document download' }, { status: 500 });
}
}

View file

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobstore';
import { ensureDocumentPreview } from '@/lib/server/documents/previews';
import { validatePreviewRequest } from '../utils';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
@ -51,7 +51,10 @@ export async function GET(req: NextRequest) {
},
);
} catch (error) {
serverLogger.error({ err: error }, 'Error ensuring document preview:');
serverLogger.error({
event: 'documents.preview.ensure.failed',
error: errorToLog(error),
}, 'Failed to ensure document preview');
return NextResponse.json({ error: 'Failed to ensure document preview' }, { status: 500 });
}
}

View file

@ -3,7 +3,7 @@ import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import {
getDocumentRange,
isMissingBlobError as isMissingDocumentBlobError,
@ -67,9 +67,12 @@ export async function GET(req: NextRequest) {
}
serverLogger.info({
id,
event: 'documents.preview.fallback.proxy_used',
degraded: true,
fallbackPath: 'preview_proxy',
documentId: id,
snippetRequested,
}, '[blob-fallback] preview proxy used');
}, 'Document preview fallback proxy used');
const rows = (await db
.select({
@ -176,7 +179,10 @@ export async function GET(req: NextRequest) {
throw error;
}
} catch (error) {
serverLogger.error({ err: error }, 'Error loading document preview fallback:');
serverLogger.error({
event: 'documents.preview.fallback.failed',
error: errorToLog(error),
}, 'Failed to load document preview fallback');
return NextResponse.json({ error: 'Failed to load document preview' }, { status: 500 });
}
}

View file

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobstore';
import { ensureDocumentPreview } from '@/lib/server/documents/previews';
import { validatePreviewRequest } from '../utils';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
@ -38,7 +38,12 @@ export async function GET(req: NextRequest) {
const directUrl = await presignDocumentPreviewGet(doc.id, testNamespace).catch(() => null);
if (!directUrl) {
serverLogger.warn({ id: doc.id }, '[blob-fallback] presign preview unavailable, redirecting to proxy fallback');
serverLogger.warn({
event: 'documents.preview.presign.unavailable',
degraded: true,
fallbackPath: 'preview_proxy',
documentId: doc.id,
}, 'Presigned document preview unavailable, redirecting to proxy fallback');
return NextResponse.redirect(fallbackUrl, {
status: 307,
headers: { 'Cache-Control': 'no-store' },
@ -50,7 +55,10 @@ export async function GET(req: NextRequest) {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
serverLogger.error({ err: error }, 'Error creating document preview signature:');
serverLogger.error({
event: 'documents.preview.presign.failed',
error: errorToLog(error),
}, 'Failed to create document preview signature');
return NextResponse.json({ error: 'Failed to prepare document preview' }, { status: 500 });
}
}

View file

@ -3,7 +3,7 @@ import { requireAuthContext } from '@/lib/server/auth/auth';
import { isValidDocumentId, putDocumentBlob } from '@/lib/server/documents/blobstore';
import { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
@ -44,14 +44,20 @@ export async function PUT(req: NextRequest) {
}
serverLogger.info({
id,
event: 'documents.blob.upload.fallback.proxy_used',
degraded: true,
fallbackPath: 'upload_proxy',
documentId: id,
contentType,
bytes: body.byteLength,
}, '[blob-fallback] upload proxy used');
}, 'Document upload fallback proxy used');
return NextResponse.json({ success: true, id });
} catch (error) {
serverLogger.error({ err: error }, 'Error proxy-uploading document blob:');
serverLogger.error({
event: 'documents.blob.upload.fallback.failed',
error: errorToLog(error),
}, 'Failed to proxy-upload document blob');
return NextResponse.json({ error: 'Failed to upload document blob' }, { status: 500 });
}
}

View file

@ -3,7 +3,7 @@ import { requireAuthContext } from '@/lib/server/auth/auth';
import { isValidDocumentId, presignPut } from '@/lib/server/documents/blobstore';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
@ -66,8 +66,10 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ uploads: signed });
} catch (error) {
serverLogger.error({ err: error }, 'Error creating document upload signatures:');
serverLogger.error({
event: 'documents.blob.upload.presign.failed',
error: errorToLog(error),
}, 'Failed to create document upload signatures');
return NextResponse.json({ error: 'Failed to presign uploads' }, { status: 500 });
}
}

View file

@ -15,7 +15,7 @@ import { stringifyDocumentParseState } from '@/lib/server/documents/parse-state'
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
import { putDocumentBlob } from '@/lib/server/documents/blobstore';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp');
@ -163,7 +163,13 @@ export async function POST(req: NextRequest) {
},
testNamespace,
).catch((error) => {
serverLogger.warn({ err: error }, `Failed to enqueue preview for converted DOCX ${id}:`);
serverLogger.warn({
event: 'documents.docx.preview_enqueue.failed',
degraded: true,
fallbackPath: 'skip_preview_enqueue',
documentId: id,
error: errorToLog(error),
}, 'Failed to enqueue preview for converted DOCX');
});
enqueueParsePdfJob({
@ -185,7 +191,10 @@ export async function POST(req: NextRequest) {
await rm(jobDir, { recursive: true, force: true }).catch(() => {});
}
} catch (error) {
serverLogger.error({ err: error }, 'Error converting/uploading DOCX:');
serverLogger.error({
event: 'documents.docx.convert_upload.failed',
error: errorToLog(error),
}, 'Failed converting/uploading DOCX');
return NextResponse.json({ error: 'Failed to convert document' }, { status: 500 });
}
}

View file

@ -5,7 +5,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { parseLibraryRoots } from '@/lib/server/storage/library-mount';
import type { DocumentType } from '@/types/documents';
import { auth } from '@/lib/server/auth/auth';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
@ -112,7 +112,10 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
} catch (error) {
serverLogger.error({ err: error }, 'Error checking auth:');
serverLogger.error({
event: 'documents.library.auth_check.failed',
error: errorToLog(error),
}, 'Failed to check auth for library route');
return NextResponse.json({ error: 'Error checking auth' }, { status: 500 });
}

View file

@ -4,7 +4,7 @@ import { db } from '@/db';
import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents/utils';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import {
cleanupDocumentPreviewArtifacts,
deleteDocumentPreviewRows,
@ -173,7 +173,13 @@ export async function POST(req: NextRequest) {
},
testNamespace,
).catch((error) => {
serverLogger.warn({ err: error }, `Failed to enqueue preview for document ${doc.id}:`);
serverLogger.warn({
event: 'documents.preview.enqueue.failed',
degraded: true,
fallbackPath: 'skip_preview_enqueue',
documentId: doc.id,
error: errorToLog(error),
}, 'Failed to enqueue document preview');
});
if (doc.type === 'pdf') {
@ -187,7 +193,10 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true, stored });
} catch (error) {
serverLogger.error({ err: error }, 'Error registering documents:');
serverLogger.error({
event: 'documents.register.failed',
error: errorToLog(error),
}, 'Failed to register documents');
return NextResponse.json({ error: 'Failed to register documents' }, { status: 500 });
}
}
@ -263,7 +272,10 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ documents: results });
} catch (error) {
serverLogger.error({ err: error }, 'Error loading document metadata:');
serverLogger.error({
event: 'documents.list.failed',
error: errorToLog(error),
}, 'Failed to load document metadata');
return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 });
}
}
@ -343,21 +355,42 @@ export async function DELETE(req: NextRequest) {
await deleteDocumentBlob(id, testNamespace);
} catch (error) {
if (!isMissingBlobError(error)) {
serverLogger.warn({ err: error }, `[best-effort] Failed to delete blob for document ${id}, orphaned blob may need manual cleanup:`);
serverLogger.warn({
event: 'documents.delete.blob_cleanup_failed',
degraded: true,
step: 'delete_document_blob',
documentId: id,
error: errorToLog(error),
}, 'Failed to delete document blob during cleanup');
}
}
await cleanupDocumentPreviewArtifacts(id, testNamespace).catch((error) => {
serverLogger.warn({ err: error }, `Failed to cleanup preview artifacts for document ${id}:`);
serverLogger.warn({
event: 'documents.delete.preview_artifacts_cleanup_failed',
degraded: true,
step: 'delete_preview_artifacts',
documentId: id,
error: errorToLog(error),
}, 'Failed to cleanup preview artifacts');
});
await deleteDocumentPreviewRows(id, testNamespace).catch((error) => {
serverLogger.warn({ err: error }, `Failed to cleanup preview rows for document ${id}:`);
serverLogger.warn({
event: 'documents.delete.preview_rows_cleanup_failed',
degraded: true,
step: 'delete_preview_rows',
documentId: id,
error: errorToLog(error),
}, 'Failed to cleanup preview rows');
});
}
return NextResponse.json({ success: true, deleted: deletedRows.length });
} catch (error) {
serverLogger.error({ err: error }, 'Error deleting documents:');
serverLogger.error({
event: 'documents.delete.failed',
error: errorToLog(error),
}, 'Failed to delete documents');
return NextResponse.json({ error: 'Failed to delete documents' }, { status: 500 });
}
}

View file

@ -6,7 +6,7 @@ import { isAuthEnabled } from '@/lib/server/auth/config';
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
@ -85,7 +85,10 @@ export async function GET(req: NextRequest) {
return response;
} catch (error) {
serverLogger.error({ err: error }, 'Error getting rate limit status:');
serverLogger.error({
event: 'rate_limit.status.get.failed',
error: errorToLog(error),
}, 'Failed to get rate limit status');
return NextResponse.json(
{ error: 'Failed to get rate limit status' },
{ status: 500 }

View file

@ -53,7 +53,7 @@ export async function GET(request: NextRequest) {
logger.error({
event: 'tts.segments.audio_fallback_failed',
error: errorToLog(error),
});
}, 'Failed to load segment audio from fallback route');
return NextResponse.json({ error: 'Failed to load segment audio' }, { status: 500 });
}
}

View file

@ -42,7 +42,7 @@ export async function POST(request: NextRequest) {
logger.error({
event: 'tts.segments.clear_failed',
error: errorToLog(error),
});
}, 'Failed to clear TTS segment cache');
return NextResponse.json({ error: 'Failed to clear TTS segment cache' }, { status: 500 });
}
}

View file

@ -335,7 +335,7 @@ export async function POST(request: NextRequest) {
documentId: parsed.documentId,
completedSoFar: manifest.length,
totalRequested: normalized.length,
});
}, 'TTS segment ensure request aborted');
break;
}
@ -778,7 +778,7 @@ export async function POST(request: NextRequest) {
segmentId: segment.segmentId,
completedSoFar: manifest.length,
totalRequested: normalized.length,
});
}, 'Stopping segment ensure after abort');
break;
}
}
@ -804,7 +804,7 @@ export async function POST(request: NextRequest) {
upstreamStatus: item.error?.upstreamStatus ?? null,
retryAfterSeconds: item.error?.retryAfterSeconds ?? null,
})),
});
}, 'TTS segment ensure completed with partial errors');
}
const response = NextResponse.json({
@ -817,7 +817,7 @@ export async function POST(request: NextRequest) {
logger.error({
event: 'tts.segments.ensure.route_failed',
error: errorToLog(error),
});
}, 'TTS segments ensure route failed');
const response = NextResponse.json({ error: 'Failed to ensure TTS segments' }, { status: 500 });
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;

View file

@ -389,7 +389,7 @@ export async function GET(request: NextRequest) {
logger.error({
event: 'tts.segments.manifest.list_failed',
error: errorToLog(error),
});
}, 'Failed to list TTS segments');
return NextResponse.json({ error: 'Failed to list TTS segments' }, { status: 500 });
}
}

View file

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/server/auth/auth';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { listAdminProviders, toPublic } from '@/lib/server/admin/providers';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
@ -24,7 +24,12 @@ export async function GET(req: NextRequest) {
const visible = all.filter((p) => p.enabled).map(toPublic);
return NextResponse.json({ providers: visible });
} catch (error) {
serverLogger.warn({ err: error }, '[tts/shared-providers] list failed:');
serverLogger.warn({
event: 'tts.shared_providers.list.failed',
degraded: true,
fallbackPath: 'empty_provider_list',
error: errorToLog(error),
}, 'Failed to list shared providers');
return NextResponse.json({ providers: [] });
}
}

View file

@ -5,7 +5,7 @@ import { defaultModelForProviderType, resolveTtsModelForProvider, resolveTtsProv
import { resolveVoices } from '@/lib/server/tts/voice-resolution';
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
export async function GET(req: NextRequest) {
try {
@ -62,7 +62,10 @@ export async function GET(req: NextRequest) {
});
return NextResponse.json({ voices });
} catch (error) {
serverLogger.error({ err: error }, 'Error in voices endpoint:');
serverLogger.error({
event: 'tts.voices.resolve.failed',
error: errorToLog(error),
}, 'Failed to resolve voices');
const providerRef = req.headers.get('x-tts-provider') || 'openai';
const model = req.headers.get('x-tts-model') || 'tts-1';
const provider = isBuiltInTtsProviderId(providerRef) ? providerRef : 'openai';

View file

@ -5,7 +5,7 @@ import { db } from '@/db';
import { audiobooks, documents, userDocumentProgress, userPreferences } from '@/db/schema';
import { count, eq, ne } from 'drizzle-orm';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
async function checkClaimMigrationReadiness(): Promise<NextResponse | null> {
const [legacyRows] = await db
@ -57,7 +57,10 @@ export async function GET(req: NextRequest) {
const counts = await getClaimableCounts(unclaimedUserId);
return NextResponse.json({ success: true, ...counts });
} catch (error) {
serverLogger.error({ err: error }, 'Error checking claimable data:');
serverLogger.error({
event: 'user.claim.status.failed',
error: errorToLog(error),
}, 'Failed checking claimable data');
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
@ -84,7 +87,10 @@ export async function POST(req: NextRequest) {
});
} catch (error) {
serverLogger.error({ err: error }, 'Error claiming data:');
serverLogger.error({
event: 'user.claim.execute.failed',
error: errorToLog(error),
}, 'Failed claiming anonymous data');
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View file

@ -11,7 +11,7 @@ import { getAudiobookObjectStream, listAudiobookObjects } from '@/lib/server/aud
import { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { nowTimestampMs } from '@/lib/shared/timestamps';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
@ -78,7 +78,12 @@ export async function GET(req: NextRequest) {
archive.on('warning', (warning) => {
if ((warning as NodeJS.ErrnoException).code !== 'ENOENT') {
serverLogger.warn({ err: warning }, 'User export warning:');
serverLogger.warn({
event: 'user.export.archive.warning',
degraded: true,
step: 'archive_warning',
error: errorToLog(warning),
}, 'User export warning');
}
});
@ -130,7 +135,10 @@ export async function GET(req: NextRequest) {
await archive.finalize();
}
} catch (error) {
serverLogger.error({ err: error }, 'Export generation failed:');
serverLogger.error({
event: 'user.export.generate.failed',
error: errorToLog(error),
}, 'Export generation failed');
archive.abort();
output.destroy(error instanceof Error ? error : new Error('Failed to generate export archive'));
} finally {

View file

@ -5,7 +5,7 @@ import { userPreferences } from '@/db/schema';
import { normalizeVersion, shouldOpenChangelogForVersionChange } from '@/lib/shared/changelog';
import { nowTimestampMs } from '@/lib/shared/timestamps';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import {
deserializeUserPreferencesPayload,
extractUserPreferencesMeta,
@ -81,7 +81,10 @@ export async function POST(req: NextRequest) {
lastSeenVersion,
});
} catch (error) {
serverLogger.error({ err: error }, 'Error checking changelog version:');
serverLogger.error({
event: 'user.changelog.version_check.failed',
error: errorToLog(error),
}, 'Failed to check changelog version');
return NextResponse.json({ error: 'Failed to check changelog version' }, { status: 500 });
}
}

View file

@ -9,7 +9,7 @@ import { isTtsProviderType, type TtsProviderId } from '@/lib/shared/tts-provider
import { listAdminProviders } from '@/lib/server/admin/providers';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import {
deserializeUserPreferencesPayload,
extractUserPreferencesMeta,
@ -242,7 +242,10 @@ export async function GET(req: NextRequest) {
hasStoredPreferences: Boolean(row),
});
} catch (error) {
serverLogger.error({ err: error }, 'Error loading user preferences:');
serverLogger.error({
event: 'user.preferences.load.failed',
error: errorToLog(error),
}, 'Failed to load user preferences');
return NextResponse.json({ error: 'Failed to load user preferences' }, { status: 500 });
}
}
@ -316,7 +319,10 @@ export async function PUT(req: NextRequest) {
applied: true,
});
} catch (error) {
serverLogger.error({ err: error }, 'Error updating user preferences:');
serverLogger.error({
event: 'user.preferences.update.failed',
error: errorToLog(error),
}, 'Failed to update user preferences');
return NextResponse.json({ error: 'Failed to update user preferences' }, { status: 500 });
}
}

View file

@ -6,7 +6,7 @@ import type { ReaderType } from '@/types/user-state';
import { isValidDocumentId } from '@/lib/server/documents/blobstore';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
@ -63,7 +63,10 @@ export async function GET(req: NextRequest) {
},
});
} catch (error) {
serverLogger.error({ err: error }, 'Error loading user progress:');
serverLogger.error({
event: 'user.progress.load.failed',
error: errorToLog(error),
}, 'Failed to load user progress');
return NextResponse.json({ error: 'Failed to load user progress' }, { status: 500 });
}
}
@ -173,7 +176,10 @@ export async function PUT(req: NextRequest) {
applied: true,
});
} catch (error) {
serverLogger.error({ err: error }, 'Error updating user progress:');
serverLogger.error({
event: 'user.progress.update.failed',
error: errorToLog(error),
}, 'Failed to update user progress');
return NextResponse.json({ error: 'Failed to update user progress' }, { status: 500 });
}
}

View file

@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger';
// We only need the `user` table here. Better Auth manages its own schema files;
// we import them lazily to avoid coupling this module to a single dialect.
@ -61,7 +61,12 @@ export async function syncAdminFlag(
await db.update(user).set({ isAdmin: shouldBeAdmin }).where(eq(user.id, userId));
return shouldBeAdmin;
} catch (error) {
serverLogger.warn({ userId: userId, err: error }, '[admin] Failed to sync isAdmin flag for user');
serverLogger.warn({
event: 'admin.email_sync.user_flag_update.failed',
userIdHash: hashForLog(userId),
degraded: true,
error: errorToLog(error),
}, 'Failed to sync isAdmin flag for user');
return currentIsAdmin;
}
}

View file

@ -3,7 +3,7 @@ import { adminProviders, adminSettings } from '@/db/schema';
import { encryptSecret, apiKeyLast4 } from '@/lib/server/crypto/secrets';
import { randomUUID } from 'node:crypto';
import { and, eq } from 'drizzle-orm';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import {
RUNTIME_CONFIG_SCHEMA,
seedRuntimeConfigFromEnv,
@ -31,7 +31,11 @@ let seedPromise: Promise<void> | null = null;
export async function ensureAdminSeed(): Promise<void> {
if (!seedPromise) {
seedPromise = runSeed().catch((error) => {
serverLogger.warn({ err: error }, '[admin-seed] failed:');
serverLogger.warn({
event: 'admin.seed.run.failed',
degraded: true,
error: errorToLog(error),
}, 'Admin seed run failed');
// Reset so a subsequent call can retry (e.g. once migrations run).
seedPromise = null;
throw error;
@ -59,7 +63,11 @@ async function seedDefaultAdminProvider(): Promise<void> {
try {
existing = await db.select({ id: adminProviders.id }).from(adminProviders).limit(1);
} catch (error) {
serverLogger.warn({ err: error }, '[admin-seed] could not check admin_providers (table missing?)');
serverLogger.warn({
event: 'admin.seed.providers.check_failed',
degraded: true,
error: errorToLog(error),
}, 'Could not check admin_providers');
return;
}
if (existing.length > 0) return;
@ -86,14 +94,24 @@ async function seedDefaultAdminProvider(): Promise<void> {
updatedAt: now,
})
.onConflictDoNothing({ target: adminSettings.key });
serverLogger.warn(
'[admin-seed] API_KEY present but AUTH_SECRET missing; defaulting restrictUserApiKeys=false so BYOK remains available',
);
serverLogger.warn({
event: 'admin.seed.restrict_user_api_keys.defaulted',
degraded: true,
step: 'set_restrict_user_api_keys_fallback',
}, 'API_KEY present but AUTH_SECRET missing; defaulting restrictUserApiKeys=false');
} catch (fallbackError) {
serverLogger.warn({ err: fallbackError }, '[admin-seed] failed to write restrictUserApiKeys fallback after encryption failure');
serverLogger.warn({
event: 'admin.seed.restrict_user_api_keys.fallback_write_failed',
degraded: true,
error: errorToLog(fallbackError),
}, 'Failed to write restrictUserApiKeys fallback after encryption failure');
}
}
serverLogger.warn({ err: error }, '[admin-seed] failed to encrypt default provider API key');
serverLogger.warn({
event: 'admin.seed.provider_key_encrypt.failed',
degraded: true,
error: errorToLog(error),
}, 'Failed to encrypt default provider API key');
return;
}
@ -112,9 +130,17 @@ async function seedDefaultAdminProvider(): Promise<void> {
createdAt: now,
updatedAt: now,
});
serverLogger.info('[admin-seed] created default-openai admin provider from env');
serverLogger.info({
event: 'admin.seed.provider_insert.succeeded',
providerSlug: 'default-openai',
}, 'Created default-openai admin provider from env');
} catch (error) {
serverLogger.warn({ err: error }, '[admin-seed] failed to insert default-openai provider');
serverLogger.warn({
event: 'admin.seed.provider_insert.failed',
degraded: true,
providerSlug: 'default-openai',
error: errorToLog(error),
}, 'Failed to insert default-openai provider');
}
}
@ -138,7 +164,11 @@ async function cleanupLegacyDefaultTtsProviderSeedRow(): Promise<void> {
),
);
} catch (error) {
serverLogger.warn({ err: error }, '[admin-seed] failed to cleanup legacy defaultTtsProvider seed row');
serverLogger.warn({
event: 'admin.seed.legacy_default_provider_cleanup.failed',
degraded: true,
error: errorToLog(error),
}, 'Failed to cleanup legacy defaultTtsProvider seed row');
}
}
@ -148,6 +178,10 @@ async function cleanupLegacyDefaultTtsModelRows(): Promise<void> {
.delete(adminSettings)
.where(eq(adminSettings.key, 'defaultTtsModel'));
} catch (error) {
serverLogger.warn({ err: error }, '[admin-seed] failed to cleanup legacy defaultTtsModel rows');
serverLogger.warn({
event: 'admin.seed.legacy_default_model_cleanup.failed',
degraded: true,
error: errorToLog(error),
}, 'Failed to cleanup legacy defaultTtsModel rows');
}
}

View file

@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm';
import { db } from '@/db';
import { adminProviders, adminSettings } from '@/db/schema';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
/**
* Runtime config: site-wide settings that used to live in build-time env vars.
@ -114,7 +114,11 @@ async function resolveImplicitDefaultTtsProvider(): Promise<string | undefined>
.limit(1);
return rows[0]?.slug;
} catch (error) {
serverLogger.warn({ err: error }, '[runtime-config] implicit defaultTtsProvider lookup failed:');
serverLogger.warn({
event: 'admin.runtime_config.default_provider_lookup.failed',
degraded: true,
error: errorToLog(error),
}, 'Implicit defaultTtsProvider lookup failed');
return undefined;
}
}
@ -142,7 +146,11 @@ async function readAllRows(): Promise<Map<string, { value: unknown; source: stri
}
return out;
} catch (error) {
serverLogger.warn({ err: error }, '[runtime-config] read failed (table may not exist yet):');
serverLogger.warn({
event: 'admin.runtime_config.read.failed',
degraded: true,
error: errorToLog(error),
}, 'Runtime config read failed');
return new Map();
}
}
@ -298,7 +306,12 @@ export async function seedRuntimeConfigFromEnv(): Promise<{ seeded: RuntimeConfi
.onConflictDoNothing({ target: adminSettings.key });
seeded.push(key);
} catch (error) {
serverLogger.warn({ key: key, err: error }, '[runtime-config] seed failed for');
serverLogger.warn({
event: 'admin.runtime_config.seed.failed',
degraded: true,
key,
error: errorToLog(error),
}, 'Runtime config seed failed');
}
}
return { seeded };

View file

@ -11,7 +11,7 @@ import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { assertUserSignupAllowed } from '@/lib/server/auth/signup-policy';
import * as authSchemaSqlite from "@/db/schema_auth_sqlite";
import * as authSchemaPostgres from "@/db/schema_auth_postgres";
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger';
// Heavy modules (S3 SDK, blobstore, rate-limiter, claim-data) are loaded
// lazily via dynamic import() inside the beforeDelete / onLinkAccount
@ -72,7 +72,10 @@ const createAuth = () => betterAuth({
requireEmailVerification: false, // Set to true in production
async sendResetPassword(data) {
// Send an email to the user with a link to reset their password
serverLogger.info({ err: data.user.email }, "Password reset requested for:");
serverLogger.info({
event: 'auth.password_reset.requested',
userEmailHash: hashForLog(data.user.email),
}, 'Password reset requested');
},
},
user: {
@ -91,7 +94,13 @@ const createAuth = () => betterAuth({
const { deleteUserStorageData } = await import('@/lib/server/user/data-cleanup');
await deleteUserStorageData(user.id, null);
} catch (error) {
serverLogger.warn({ err: error }, '[auth] Failed to clean up user storage before deletion:');
serverLogger.warn({
event: 'auth.user_delete.storage_cleanup_failed',
degraded: true,
step: 'delete_user_storage',
userIdHash: hashForLog(user.id),
error: errorToLog(error),
}, 'Failed to clean up user storage before deletion');
// Don't throw allow the user deletion to proceed even if S3 cleanup fails.
// Orphaned blobs are preferable to a blocked account deletion.
}
@ -147,10 +156,11 @@ const createAuth = () => betterAuth({
try {
// Log when anonymous user links to a real account
serverLogger.info({
anonymousUserId: anonymousUser.user.id,
newUserId: newUser.user.id,
newUserEmail: newUser.user.email,
}, "Anonymous user linked to account:");
event: 'auth.link_account.started',
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
newUserEmailHash: hashForLog(newUser.user.email),
}, 'Anonymous user linked to account');
// Lazy-load heavy modules only when account linking actually happens
const [{ rateLimiter }, claimData] = await Promise.all([
@ -161,9 +171,20 @@ const createAuth = () => betterAuth({
// Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user
try {
await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id);
serverLogger.info(`Successfully transferred rate limit data from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
serverLogger.info({
event: 'auth.link_account.transfer.rate_limit.succeeded',
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
}, 'Transferred rate limit data during account linking');
} catch (error) {
serverLogger.warn({ err: error }, "Error transferring rate limit data during account linking:");
serverLogger.warn({
event: 'auth.link_account.transfer.rate_limit.failed',
degraded: true,
step: 'transfer_rate_limit',
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
error: errorToLog(error),
}, 'Failed transferring rate limit data during account linking');
// Don't throw here to prevent blocking the account linking process
}
@ -171,10 +192,22 @@ const createAuth = () => betterAuth({
try {
const transferred = await claimData.transferUserAudiobooks(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
serverLogger.info(`Successfully transferred ${transferred} audiobook(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
serverLogger.info({
event: 'auth.link_account.transfer.audiobooks.succeeded',
transferred,
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
}, 'Transferred audiobooks during account linking');
}
} catch (error) {
serverLogger.warn({ err: error }, "Error transferring audiobooks during account linking:");
serverLogger.warn({
event: 'auth.link_account.transfer.audiobooks.failed',
degraded: true,
step: 'transfer_audiobooks',
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
error: errorToLog(error),
}, 'Failed transferring audiobooks during account linking');
// Don't throw here to prevent blocking the account linking process
}
@ -182,10 +215,22 @@ const createAuth = () => betterAuth({
try {
const transferred = await claimData.transferUserDocuments(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
serverLogger.info(`Successfully transferred ${transferred} document(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
serverLogger.info({
event: 'auth.link_account.transfer.documents.succeeded',
transferred,
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
}, 'Transferred documents during account linking');
}
} catch (error) {
serverLogger.warn({ err: error }, "Error transferring documents during account linking:");
serverLogger.warn({
event: 'auth.link_account.transfer.documents.failed',
degraded: true,
step: 'transfer_documents',
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
error: errorToLog(error),
}, 'Failed transferring documents during account linking');
// Don't throw here to prevent blocking the account linking process
}
@ -193,10 +238,22 @@ const createAuth = () => betterAuth({
try {
const transferred = await claimData.transferUserPreferences(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
serverLogger.info(`Successfully transferred preferences from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
serverLogger.info({
event: 'auth.link_account.transfer.preferences.succeeded',
transferred,
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
}, 'Transferred preferences during account linking');
}
} catch (error) {
serverLogger.warn({ err: error }, "Error transferring preferences during account linking:");
serverLogger.warn({
event: 'auth.link_account.transfer.preferences.failed',
degraded: true,
step: 'transfer_preferences',
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
error: errorToLog(error),
}, 'Failed transferring preferences during account linking');
// Don't throw here to prevent blocking the account linking process
}
@ -204,14 +261,29 @@ const createAuth = () => betterAuth({
try {
const transferred = await claimData.transferUserProgress(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
serverLogger.info(`Successfully transferred ${transferred} progress row(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
serverLogger.info({
event: 'auth.link_account.transfer.progress.succeeded',
transferred,
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
}, 'Transferred reading progress during account linking');
}
} catch (error) {
serverLogger.warn({ err: error }, "Error transferring reading progress during account linking:");
serverLogger.warn({
event: 'auth.link_account.transfer.progress.failed',
degraded: true,
step: 'transfer_progress',
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
error: errorToLog(error),
}, 'Failed transferring reading progress during account linking');
// Don't throw here to prevent blocking the account linking process
}
} catch (error) {
serverLogger.error({ err: error }, "Error in onLinkAccount callback:");
serverLogger.error({
event: 'auth.link_account.failed',
error: errorToLog(error),
}, 'onLinkAccount callback failed');
// Don't throw here to prevent blocking the account linking process
}
// Note: Anonymous user will be automatically deleted after this callback completes

View file

@ -1,6 +1,6 @@
import { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker';
import type { WorkerOperationState } from '@openreader/compute-core/api-contracts';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
const WORKER_OP_REQUEST_TIMEOUT_MS = 2_500;
@ -15,9 +15,11 @@ export async function fetchWorkerOperationState<Result>(
cfg = getWorkerClientConfigFromEnv();
} catch (error) {
serverLogger.warn({
event: 'compute.worker_op_state.config.invalid',
opId: normalized,
error: error instanceof Error ? error.message : String(error),
}, '[worker-op-state] worker client env missing/invalid');
degraded: true,
error: errorToLog(error),
}, 'Worker client env missing/invalid');
return null;
}
@ -38,25 +40,31 @@ export async function fetchWorkerOperationState<Result>(
if (!res.ok) {
const detail = await res.text().catch(() => '');
serverLogger.warn({
event: 'compute.worker_op_state.fetch.failed',
opId: normalized,
status: res.status,
detail,
}, '[worker-op-state] worker op request failed');
degraded: true,
}, 'Worker op request failed');
return null;
}
const parsed = await res.json() as WorkerOperationState<Result>;
if (!parsed || typeof parsed !== 'object' || parsed.opId !== normalized) {
serverLogger.warn({
event: 'compute.worker_op_state.response.invalid',
opId: normalized,
}, '[worker-op-state] worker op response invalid');
degraded: true,
}, 'Worker op response invalid');
return null;
}
return parsed;
} catch (error) {
serverLogger.warn({
event: 'compute.worker_op_state.fetch.error',
opId: normalized,
error: error instanceof Error ? error.message : String(error),
}, '[worker-op-state] worker op request threw');
degraded: true,
error: errorToLog(error),
}, 'Worker op request threw');
return null;
} finally {
clearTimeout(timeout);

View file

@ -25,7 +25,6 @@ class WorkerHttpError extends Error {
}
const DEFAULT_RETRIES = 2;
const LOG_PREFIX = '[compute-worker-client]';
const MAX_LOG_DETAIL_CHARS = 600;
const LOG_EVENTS = new Set([
'align.request.failed',
@ -63,22 +62,27 @@ function errorToLog(error: unknown): Record<string, unknown> {
function logWorker(level: WorkerLogLevel, event: string, fields: Record<string, unknown>): void {
if (!LOG_EVENTS.has(event)) return;
const payload = {
ts: new Date().toISOString(),
event,
...fields,
};
const line = `${LOG_PREFIX} ${JSON.stringify(payload)}`;
if (level === 'error') {
serverLogger.error(line);
serverLogger.error({
event: `compute.worker_client.${event}`,
operation: 'compute_worker_client',
...fields,
}, `Worker client ${event}`);
return;
}
if (level === 'warn') {
serverLogger.warn(line);
serverLogger.warn({
event: `compute.worker_client.${event}`,
operation: 'compute_worker_client',
...fields,
}, `Worker client ${event}`);
return;
}
serverLogger.info(line);
serverLogger.info({
event: `compute.worker_client.${event}`,
operation: 'compute_worker_client',
...fields,
}, `Worker client ${event}`);
}
function opSummary(value: unknown): Record<string, unknown> {

View file

@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
import { and, eq, inArray, lt, or, sql } from 'drizzle-orm';
import { db } from '@/db';
import { documentPreviews } from '@/db/schema';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import {
DOCUMENT_PREVIEW_CONTENT_TYPE,
DOCUMENT_PREVIEW_VARIANT,
@ -375,7 +375,12 @@ export async function ensureDocumentPreview(doc: PreviewSourceDocument, namespac
eTag: head.eTag,
});
} catch (error) {
serverLogger.error({ err: error }, `[document-previews] Preview generation failed for ${doc.id} (type=${doc.type}):`);
serverLogger.error({
event: 'documents.preview.generate.failed',
documentId: doc.id,
documentType: doc.type,
error: errorToLog(error),
}, 'Preview generation failed');
await markPreviewFailed(doc.id, namespaceKey, error);
}
}

View file

@ -2,7 +2,7 @@ import { and, eq, inArray, isNull } from 'drizzle-orm';
import { db } from '@/db';
import { documents } from '@/db/schema';
import { documentKey, putParsedDocumentBlob } from '@/lib/server/documents/blobstore';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import {
parseDocumentParseState,
stringifyDocumentParseState,
@ -350,17 +350,23 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise<void>
}).then((cleared) => {
if (cleared.warning) {
serverLogger.warn({
event: 'documents.parse.cache_invalidation.warning',
degraded: true,
step: 'clear_tts_segment_cache',
documentId: input.documentId,
userId,
warning: cleared.warning,
}, '[parsePdfJob] cache invalidation warning');
}, 'Parse cache invalidation warning');
}
}).catch((cacheError) => {
serverLogger.warn({
event: 'documents.parse.cache_invalidation.failed',
degraded: true,
step: 'clear_tts_segment_cache',
documentId: input.documentId,
userId,
error: cacheError instanceof Error ? cacheError.message : String(cacheError),
}, '[parsePdfJob] cache invalidation failed');
error: errorToLog(cacheError),
}, 'Parse cache invalidation failed');
});
}
} catch (error) {
@ -385,18 +391,20 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise<void>
});
} catch (statusError) {
serverLogger.error({
event: 'documents.parse.status_write.failed',
documentId: input.documentId,
parseStatus,
error: statusError instanceof Error ? statusError.message : String(statusError),
}, '[parsePdfJob] failed to write parse status');
error: errorToLog(statusError),
}, 'Failed to write parse status');
}
serverLogger.error({
event: 'documents.parse.job.failed',
documentId: input.documentId,
parseStatus,
error: message,
...(stack ? { stack } : {}),
...(cause ? { cause: String(cause) } : {}),
}, '[parsePdfJob] failed');
}, 'Parse job failed');
} finally {
running.delete(key);
}
@ -406,6 +414,10 @@ export function enqueueParsePdfJob(input: UserPdfLayoutJobRequest): void {
Promise.resolve()
.then(() => parsePdfJob(input))
.catch((error) => {
serverLogger.error({ err: error }, '[parsePdfJob] uncaught error');
serverLogger.error({
event: 'documents.parse.job.uncaught_error',
documentId: input.documentId,
error: errorToLog(error),
}, 'Parse job uncaught error');
});
}

View file

@ -4,6 +4,34 @@ import pino, { type Logger } from 'pino';
import pinoPretty from 'pino-pretty';
export type ServerLogger = Logger;
export type ServerLogLevel = 'error' | 'warn' | 'info';
export type ServerErrorClass =
| 'validation'
| 'auth'
| 'permission'
| 'upstream'
| 'storage'
| 'db'
| 'timeout'
| 'unknown';
export type ServerErrorContract = {
errorCode: string;
errorClass: ServerErrorClass;
retryable: boolean;
httpStatus: number;
operation: string;
degraded: boolean;
cause?: unknown;
};
export type ServerApiErrorBody = {
error: string;
errorCode: string;
retryable?: boolean;
details?: Record<string, unknown>;
};
const LOG_FORMAT = process.env.LOG_FORMAT?.trim().toLowerCase() || 'pretty';
const LOG_LEVEL = process.env.LOG_LEVEL?.trim() || 'info';
@ -48,6 +76,17 @@ export function errorToLog(error: unknown): Record<string, unknown> {
return { message: String(error) };
}
export function apiErrorBody(
input: Omit<ServerApiErrorBody, 'errorCode'> & { errorCode: string },
): ServerApiErrorBody {
return {
error: input.error,
errorCode: input.errorCode,
...(typeof input.retryable === 'boolean' ? { retryable: input.retryable } : {}),
...(input.details ? { details: input.details } : {}),
};
}
export function hashForLog(value: string | null | undefined): string | null {
const normalized = typeof value === 'string' ? value.trim() : '';
if (!normalized) return null;

View file

@ -11,7 +11,13 @@ function readPositiveIntEnv(name: string, fallback: number): number {
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
serverLogger.warn(`[rate-limiter] Invalid ${name}=${raw}; using default ${fallback}`);
serverLogger.warn({
event: 'rate_limit.config.invalid_env',
degraded: true,
envVar: name,
envValue: raw,
fallbackValue: fallback,
}, 'Invalid rate limiter env value; using default');
return fallback;
}

View file

@ -3,7 +3,7 @@ import { db } from '@/db';
import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore';
import type { ReaderType } from '@/types/user-state';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
type ClearTtsSegmentCacheInput = {
userId: string;
@ -67,10 +67,13 @@ export async function clearTtsSegmentCache(
} catch (error) {
warning = error instanceof Error ? error.message : 'Failed deleting some audio objects';
serverLogger.warn({
event: 'tts.segments.cache.audio_cleanup_failed',
degraded: true,
step: 'delete_tts_audio_objects',
documentId: input.documentId,
userId: input.userId,
error: warning,
}, 'Failed clearing some TTS segment audio objects:');
error: errorToLog(error),
}, 'Failed clearing some TTS segment audio objects');
}
}

View file

@ -1,5 +1,5 @@
import { LRUCache } from 'lru-cache';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import {
resolveProviderModels,
type ReplicateVoiceInputKey,
@ -193,7 +193,10 @@ async function fetchReplicateOpenApiSchema(apiKey: string, model: string): Promi
if (error instanceof DOMException && error.name === 'AbortError') {
return null;
}
serverLogger.error({ err: error }, 'Error fetching Replicate model schema:');
serverLogger.error({
event: 'tts.voice_resolution.replicate_schema_fetch.failed',
error: errorToLog(error),
}, 'Failed fetching Replicate model schema');
return null;
} finally {
clearTimeout(timeoutId);
@ -284,7 +287,10 @@ async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
if (error instanceof DOMException && error.name === 'AbortError') {
return [];
}
serverLogger.error({ err: error }, 'Error fetching Deepinfra voices:');
serverLogger.error({
event: 'tts.voice_resolution.deepinfra_voices_fetch.failed',
error: errorToLog(error),
}, 'Failed fetching Deepinfra voices');
return [];
} finally {
clearTimeout(timeoutId);
@ -316,7 +322,11 @@ async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise
? data.voices
: null;
} catch {
serverLogger.info('Custom endpoint does not support voices, using defaults');
serverLogger.info({
event: 'tts.voice_resolution.custom_endpoint.voices_unsupported',
degraded: true,
fallbackPath: 'provider_default_voices',
}, 'Custom endpoint does not support voices, using defaults');
return null;
} finally {
clearTimeout(timeoutId);

View file

@ -13,7 +13,7 @@ import { deleteDocumentPreviewArtifacts } from '@/lib/server/documents/previews-
import { deleteDocumentPreviewRows } from '@/lib/server/documents/previews';
import { audiobookPrefix, deleteAudiobookPrefix } from '@/lib/server/audiobooks/blobstore';
import { deleteTtsSegmentPrefix } from '@/lib/server/tts/segments-blobstore';
import { serverLogger } from '@/lib/server/logger';
import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger';
type DocumentRow = { id: string };
type AudiobookRow = { id: string };
@ -50,13 +50,27 @@ export async function deleteUserStorageData(
await deleteDocumentBlob(doc.id, namespace);
docsDeleted++;
} catch (error) {
serverLogger.warn({ err: error }, `[user-data-cleanup] Failed to delete document blob ${doc.id}:`);
serverLogger.warn({
event: 'user.data_cleanup.document_blob_delete.failed',
degraded: true,
step: 'delete_document_blob',
documentId: doc.id,
userIdHash: hashForLog(userId),
error: errorToLog(error),
}, 'Failed to delete document blob');
}
try {
await deleteDocumentPreviewArtifacts(doc.id, namespace);
} catch (error) {
serverLogger.warn({ err: error }, `[user-data-cleanup] Failed to delete preview for ${doc.id}:`);
serverLogger.warn({
event: 'user.data_cleanup.document_preview_delete.failed',
degraded: true,
step: 'delete_document_preview_artifacts',
documentId: doc.id,
userIdHash: hashForLog(userId),
error: errorToLog(error),
}, 'Failed to delete preview artifacts');
}
}
@ -64,7 +78,14 @@ export async function deleteUserStorageData(
try {
await deleteDocumentPreviewRows(doc.id, namespace);
} catch (error) {
serverLogger.warn({ err: error }, `[user-data-cleanup] Failed to delete preview rows for ${doc.id}:`);
serverLogger.warn({
event: 'user.data_cleanup.document_preview_rows_delete.failed',
degraded: true,
step: 'delete_document_preview_rows',
documentId: doc.id,
userIdHash: hashForLog(userId),
error: errorToLog(error),
}, 'Failed to delete preview rows');
}
}
@ -83,7 +104,14 @@ export async function deleteUserStorageData(
await deleteAudiobookPrefix(prefix);
booksDeleted++;
} catch (error) {
serverLogger.warn({ err: error }, `[user-data-cleanup] Failed to delete audiobook blobs ${book.id}:`);
serverLogger.warn({
event: 'user.data_cleanup.audiobook_blobs_delete.failed',
degraded: true,
step: 'delete_audiobook_prefix',
bookId: book.id,
userIdHash: hashForLog(userId),
error: errorToLog(error),
}, 'Failed to delete audiobook blobs');
}
}
@ -98,16 +126,25 @@ export async function deleteUserStorageData(
segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV1);
segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV2);
} catch (error) {
serverLogger.warn({ err: error }, `[user-data-cleanup] Failed to delete TTS segment blobs for user ${userId}:`);
serverLogger.warn({
event: 'user.data_cleanup.tts_segments_delete.failed',
degraded: true,
step: 'delete_tts_segment_prefixes',
userIdHash: hashForLog(userId),
error: errorToLog(error),
}, 'Failed to delete TTS segment blobs');
}
}
if (docsDeleted > 0 || booksDeleted > 0 || segmentsDeleted > 0) {
serverLogger.info(
`[user-data-cleanup] Cleaned up S3 data for user ${userId}: ` +
`${docsDeleted}/${userDocs.length} document(s), ` +
`${booksDeleted}/${userBooks.length} audiobook(s), ` +
`${segmentsDeleted} tts segment object(s)`,
);
serverLogger.info({
event: 'user.data_cleanup.completed',
userIdHash: hashForLog(userId),
docsDeleted,
totalDocs: userDocs.length,
booksDeleted,
totalBooks: userBooks.length,
segmentsDeleted,
}, 'Completed user storage cleanup');
}
}