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"], files: ["src/app/api/**/*.ts", "src/lib/server/**/*.ts"],
rules: { rules: {
"no-console": "error", "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 { isAuthEnabled } from '@/lib/server/auth/config';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { deleteUserStorageData } from '@/lib/server/user/data-cleanup'; 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() { export async function DELETE() {
if (!isAuthEnabled() || !auth) { if (!isAuthEnabled() || !auth) {
@ -29,7 +29,13 @@ export async function DELETE() {
try { try {
await deleteUserStorageData(session.user.id, testNamespace); await deleteUserStorageData(session.user.id, testNamespace);
} catch (error) { } 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 }); return NextResponse.json({ success: true });
} catch (error) { } 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( return NextResponse.json(
{ error: 'Failed to delete account' }, { error: 'Failed to delete account' },
{ status: 500 } { status: 500 }

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { requireAdminContext } from '@/lib/server/auth/admin'; import { requireAdminContext } from '@/lib/server/auth/admin';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { import {
AdminProviderError, AdminProviderError,
deleteAdminProvider, deleteAdminProvider,
@ -42,7 +42,11 @@ export async function PUT(
if (error instanceof AdminProviderError) { if (error instanceof AdminProviderError) {
return NextResponse.json({ error: error.message }, { status: error.status }); 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 }); return NextResponse.json({ error: 'Internal error' }, { status: 500 });
} }
} }
@ -62,7 +66,11 @@ export async function DELETE(
if (error instanceof AdminProviderError) { if (error instanceof AdminProviderError) {
return NextResponse.json({ error: error.message }, { status: error.status }); 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 }); return NextResponse.json({ error: 'Internal error' }, { status: 500 });
} }
} }

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { requireAdminContext } from '@/lib/server/auth/admin'; import { requireAdminContext } from '@/lib/server/auth/admin';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { import {
AdminProviderError, AdminProviderError,
createAdminProvider, createAdminProvider,
@ -47,7 +47,10 @@ export async function POST(req: NextRequest) {
if (error instanceof AdminProviderError) { if (error instanceof AdminProviderError) {
return NextResponse.json({ error: error.message }, { status: error.status }); 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 }); 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 { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { getClientIp } from '@/lib/server/rate-limit/request-ip'; import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { import {
deleteAudiobookObject, deleteAudiobookObject,
getAudiobookObjectBuffer, getAudiobookObjectBuffer,
@ -186,7 +186,12 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
} }
ffmpeg.stderr.on('data', (data) => { 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) => { ffmpeg.on('close', (code) => {
@ -326,7 +331,11 @@ export async function POST(request: NextRequest) {
fallbackProviderRef: runtimeConfig.defaultTtsProvider, fallbackProviderRef: runtimeConfig.defaultTtsProvider,
}); });
if (!existingResult.settings) { 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 }); return NextResponse.json({ error: 'Invalid audiobook metadata settings' }, { status: 500 });
} }
normalizedExistingSettings = normalizeNativeSpeedForSettings(existingResult.settings); normalizedExistingSettings = normalizeNativeSpeedForSettings(existingResult.settings);
@ -401,6 +410,9 @@ export async function POST(request: NextRequest) {
); );
} catch (error) { } catch (error) {
serverLogger.warn({ serverLogger.warn({
event: 'audiobook.chapter.meta_settings.persist_migration_failed',
degraded: true,
step: 'persist_migrated_settings',
bookId, bookId,
storageUserId, storageUserId,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
@ -609,7 +621,12 @@ export async function POST(request: NextRequest) {
request.signal, request.signal,
); );
} catch (copyError) { } 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( await runFFmpeg(
chapterEncodeArgs(inputPath, chapterOutputTempPath, format, postSpeed, titleTag), chapterEncodeArgs(inputPath, chapterOutputTempPath, format, postSpeed, titleTag),
request.signal, request.signal,
@ -729,7 +746,10 @@ export async function POST(request: NextRequest) {
return response; 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 }); const response = NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 });
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response; return response;
@ -824,7 +844,10 @@ export async function GET(request: NextRequest) {
}, },
}); });
} catch (error) { } 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 }); 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 }); return NextResponse.json({ success: true });
} catch (error) { } 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 }); 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 { db } from '@/db';
import { audiobooks, audiobookChapters } from '@/db/schema'; import { audiobooks, audiobookChapters } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth'; import { requireAuthContext } from '@/lib/server/auth/auth';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { import {
audiobookPrefix, audiobookPrefix,
deleteAudiobookObject, deleteAudiobookObject,
@ -116,7 +116,12 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
} }
ffmpeg.stderr.on('data', (data) => { 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) => { ffmpeg.on('close', (code) => {
@ -283,7 +288,12 @@ export async function GET(request: NextRequest) {
request.signal, request.signal,
); );
} catch (copyError) { } 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( await runFFmpeg(
['-f', 'concat', '-safe', '0', '-i', listPath, '-c:a', 'libmp3lame', '-b:a', '64k', outputPath], ['-f', 'concat', '-safe', '0', '-i', listPath, '-c:a', 'libmp3lame', '-b:a', '64k', outputPath],
request.signal, request.signal,
@ -312,7 +322,12 @@ export async function GET(request: NextRequest) {
request.signal, request.signal,
); );
} catch (copyError) { } 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( await runFFmpeg(
[ [
'-f', '-f',
@ -361,7 +376,10 @@ export async function GET(request: NextRequest) {
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) { if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
return NextResponse.json({ error: 'cancelled' }, { status: 499 }); 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 }); return NextResponse.json({ error: 'Failed to create full audiobook file' }, { status: 500 });
} finally { } finally {
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {}); 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); const deleted = await deleteAudiobookPrefix(audiobookPrefix(bookId, storageUserId, testNamespace)).catch(() => 0);
return NextResponse.json({ success: true, existed: deleted > 0 }); return NextResponse.json({ success: true, existed: deleted > 0 });
} catch (error) { } 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 }); 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 { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope';
import type { AudiobookGenerationSettings } from '@/types/client'; import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; 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'; export const dynamic = 'force-dynamic';
@ -156,7 +156,10 @@ export async function GET(request: NextRequest) {
settings, settings,
}); });
} catch (error) { } 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 }); 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, storageUserIdHash,
requestId, requestId,
error: error instanceof Error ? error.message : String(error), 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`)); controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`));
}); });
}, SSE_RESYNC_INTERVAL_MS); }, SSE_RESYNC_INTERVAL_MS);
@ -306,7 +306,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
storageUserIdHash, storageUserIdHash,
parseStatus: current.parseStatus, parseStatus: current.parseStatus,
requestedOpId, requestedOpId,
}); }, 'Parse stream running without opId and non-terminal status');
} }
} else if (loggedMissingOpId) { } else if (loggedMissingOpId) {
loggedMissingOpId = false; loggedMissingOpId = false;
@ -346,7 +346,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
opId: currentOpId, opId: currentOpId,
status: response.status, status: response.status,
detail, detail,
}); }, 'Worker stream request failed');
await sleep(500); await sleep(500);
continue; continue;
} }
@ -355,7 +355,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
event: 'documents.parsed.events.worker_stream_missing_body', event: 'documents.parsed.events.worker_stream_missing_body',
documentId: id, documentId: id,
opId: currentOpId, opId: currentOpId,
}); }, 'Worker stream response missing body');
await sleep(500); await sleep(500);
continue; continue;
} }
@ -456,7 +456,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
event: 'documents.parsed.events.worker_proxy_crashed', event: 'documents.parsed.events.worker_proxy_crashed',
documentId: id, documentId: id,
error: errorToLog(error), error: errorToLog(error),
}); }, 'Worker proxy crashed while streaming parse events');
if (!closed) { if (!closed) {
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`)); 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({ logger.error({
event: 'documents.parsed.events.route_failed', event: 'documents.parsed.events.route_failed',
error: errorToLog(error), error: errorToLog(error),
}); }, 'Parsed events route failed');
return NextResponse.json({ error: 'Failed to stream parsed PDF progress' }, { status: 500 }); 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, documentId: input.row.id,
userIdHash: hashForLog(input.row.userId), userIdHash: hashForLog(input.row.userId),
parsedJsonKey, parsedJsonKey,
}); }, 'Worker output parsed successfully but contained no blocks');
} }
return new NextResponse(new Uint8Array(json), { return new NextResponse(new Uint8Array(json), {
@ -223,7 +223,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
documentId: id, documentId: id,
userIdHash: hashForLog(row.userId), userIdHash: hashForLog(row.userId),
opId: requestedOpId, opId: requestedOpId,
}); }, 'Requested worker operation id was unavailable');
} }
let state = parseDocumentParseState(row.parseState); let state = parseDocumentParseState(row.parseState);
@ -288,7 +288,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
documentId: id, documentId: id,
userIdHash: hashForLog(row.userId), userIdHash: hashForLog(row.userId),
parsedJsonKey: row.parsedJsonKey, parsedJsonKey: row.parsedJsonKey,
}); }, 'Parsed document blob contained no blocks');
} }
return new NextResponse(new Uint8Array(json), { return new NextResponse(new Uint8Array(json), {
@ -308,7 +308,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
logger.error({ logger.error({
event: 'documents.parsed.get_failed', event: 'documents.parsed.get_failed',
error: errorToLog(error), error: errorToLog(error),
}); }, 'Failed to read parsed PDF');
return NextResponse.json({ error: 'Failed to read parsed PDF' }, { status: 500 }); 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({ logger.error({
event: 'documents.parsed.force_refresh_failed', event: 'documents.parsed.force_refresh_failed',
error: errorToLog(error), error: errorToLog(error),
}); }, 'Failed to force PDF refresh');
return NextResponse.json({ error: 'Failed to force PDF refresh' }, { status: 500 }); 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 { mergeDocumentSettings } from '@/lib/shared/document-settings';
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings'; import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; 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'; export const dynamic = 'force-dynamic';
@ -93,7 +93,10 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
hasStoredSettings: Boolean(row), hasStoredSettings: Boolean(row),
}); });
} catch (error) { } 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 }); 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, applied: true,
}); });
} catch (error) { } 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 }); 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 { getDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3'; 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'; export const dynamic = 'force-dynamic';
@ -45,7 +45,12 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); 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 const rows = (await db
.select({ id: documents.id, userId: documents.userId, name: documents.name }) .select({ id: documents.id, userId: documents.userId, name: documents.name })
@ -82,7 +87,10 @@ export async function GET(req: NextRequest) {
throw error; throw error;
} }
} catch (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 }); 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 { isValidDocumentId, presignGet } from '@/lib/server/documents/blobstore';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3'; 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'; 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 fallbackUrl = `/api/documents/blob/get/fallback?id=${encodeURIComponent(doc.id)}`;
const directUrl = await presignGet(doc.id, testNamespace).catch(() => null); const directUrl = await presignGet(doc.id, testNamespace).catch(() => null);
if (!directUrl) { 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, { return NextResponse.redirect(fallbackUrl, {
status: 307, status: 307,
headers: { 'Cache-Control': 'no-store' }, headers: { 'Cache-Control': 'no-store' },
@ -63,7 +68,10 @@ export async function GET(req: NextRequest) {
headers: { 'Cache-Control': 'no-store' }, headers: { 'Cache-Control': 'no-store' },
}); });
} catch (error) { } 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 }); 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 { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobstore';
import { ensureDocumentPreview } from '@/lib/server/documents/previews'; import { ensureDocumentPreview } from '@/lib/server/documents/previews';
import { validatePreviewRequest } from '../utils'; import { validatePreviewRequest } from '../utils';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -51,7 +51,10 @@ export async function GET(req: NextRequest) {
}, },
); );
} catch (error) { } 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 }); 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 { db } from '@/db';
import { documents } from '@/db/schema'; import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth'; import { requireAuthContext } from '@/lib/server/auth/auth';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { import {
getDocumentRange, getDocumentRange,
isMissingBlobError as isMissingDocumentBlobError, isMissingBlobError as isMissingDocumentBlobError,
@ -67,9 +67,12 @@ export async function GET(req: NextRequest) {
} }
serverLogger.info({ serverLogger.info({
id, event: 'documents.preview.fallback.proxy_used',
degraded: true,
fallbackPath: 'preview_proxy',
documentId: id,
snippetRequested, snippetRequested,
}, '[blob-fallback] preview proxy used'); }, 'Document preview fallback proxy used');
const rows = (await db const rows = (await db
.select({ .select({
@ -176,7 +179,10 @@ export async function GET(req: NextRequest) {
throw error; throw error;
} }
} catch (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 }); 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 { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobstore';
import { ensureDocumentPreview } from '@/lib/server/documents/previews'; import { ensureDocumentPreview } from '@/lib/server/documents/previews';
import { validatePreviewRequest } from '../utils'; import { validatePreviewRequest } from '../utils';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -38,7 +38,12 @@ export async function GET(req: NextRequest) {
const directUrl = await presignDocumentPreviewGet(doc.id, testNamespace).catch(() => null); const directUrl = await presignDocumentPreviewGet(doc.id, testNamespace).catch(() => null);
if (!directUrl) { 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, { return NextResponse.redirect(fallbackUrl, {
status: 307, status: 307,
headers: { 'Cache-Control': 'no-store' }, headers: { 'Cache-Control': 'no-store' },
@ -50,7 +55,10 @@ export async function GET(req: NextRequest) {
headers: { 'Cache-Control': 'no-store' }, headers: { 'Cache-Control': 'no-store' },
}); });
} catch (error) { } 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 }); 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 { isValidDocumentId, putDocumentBlob } from '@/lib/server/documents/blobstore';
import { isS3Configured } from '@/lib/server/storage/s3'; import { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; 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'; export const dynamic = 'force-dynamic';
@ -44,14 +44,20 @@ export async function PUT(req: NextRequest) {
} }
serverLogger.info({ serverLogger.info({
id, event: 'documents.blob.upload.fallback.proxy_used',
degraded: true,
fallbackPath: 'upload_proxy',
documentId: id,
contentType, contentType,
bytes: body.byteLength, bytes: body.byteLength,
}, '[blob-fallback] upload proxy used'); }, 'Document upload fallback proxy used');
return NextResponse.json({ success: true, id }); return NextResponse.json({ success: true, id });
} catch (error) { } 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 }); 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 { isValidDocumentId, presignPut } from '@/lib/server/documents/blobstore';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3'; 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'; export const dynamic = 'force-dynamic';
@ -66,8 +66,10 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ uploads: signed }); return NextResponse.json({ uploads: signed });
} catch (error) { } 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 }); 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 { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3'; import { isS3Configured } from '@/lib/server/storage/s3';
import { putDocumentBlob } from '@/lib/server/documents/blobstore'; 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 DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp'); const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp');
@ -163,7 +163,13 @@ export async function POST(req: NextRequest) {
}, },
testNamespace, testNamespace,
).catch((error) => { ).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({ enqueueParsePdfJob({
@ -185,7 +191,10 @@ export async function POST(req: NextRequest) {
await rm(jobDir, { recursive: true, force: true }).catch(() => {}); await rm(jobDir, { recursive: true, force: true }).catch(() => {});
} }
} catch (error) { } 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 }); 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 { parseLibraryRoots } from '@/lib/server/storage/library-mount';
import type { DocumentType } from '@/types/documents'; import type { DocumentType } from '@/types/documents';
import { auth } from '@/lib/server/auth/auth'; 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'; export const dynamic = 'force-dynamic';
@ -112,7 +112,10 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
} catch (error) { } 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 }); 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 { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth'; import { requireAuthContext } from '@/lib/server/auth/auth';
import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents/utils'; import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents/utils';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { import {
cleanupDocumentPreviewArtifacts, cleanupDocumentPreviewArtifacts,
deleteDocumentPreviewRows, deleteDocumentPreviewRows,
@ -173,7 +173,13 @@ export async function POST(req: NextRequest) {
}, },
testNamespace, testNamespace,
).catch((error) => { ).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') { if (doc.type === 'pdf') {
@ -187,7 +193,10 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true, stored }); return NextResponse.json({ success: true, stored });
} catch (error) { } 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 }); 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 }); return NextResponse.json({ documents: results });
} catch (error) { } 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 }); return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 });
} }
} }
@ -343,21 +355,42 @@ export async function DELETE(req: NextRequest) {
await deleteDocumentBlob(id, testNamespace); await deleteDocumentBlob(id, testNamespace);
} catch (error) { } catch (error) {
if (!isMissingBlobError(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) => { 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) => { 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 }); return NextResponse.json({ success: true, deleted: deletedRows.length });
} catch (error) { } 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 }); 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 { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps'; import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -85,7 +85,10 @@ export async function GET(req: NextRequest) {
return response; return response;
} catch (error) { } 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( return NextResponse.json(
{ error: 'Failed to get rate limit status' }, { error: 'Failed to get rate limit status' },
{ status: 500 } { status: 500 }

View file

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

View file

@ -389,7 +389,7 @@ export async function GET(request: NextRequest) {
logger.error({ logger.error({
event: 'tts.segments.manifest.list_failed', event: 'tts.segments.manifest.list_failed',
error: errorToLog(error), error: errorToLog(error),
}); }, 'Failed to list TTS segments');
return NextResponse.json({ error: 'Failed to list TTS segments' }, { status: 500 }); 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 { auth } from '@/lib/server/auth/auth';
import { isAuthEnabled } from '@/lib/server/auth/config'; import { isAuthEnabled } from '@/lib/server/auth/config';
import { listAdminProviders, toPublic } from '@/lib/server/admin/providers'; 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'; export const dynamic = 'force-dynamic';
@ -24,7 +24,12 @@ export async function GET(req: NextRequest) {
const visible = all.filter((p) => p.enabled).map(toPublic); const visible = all.filter((p) => p.enabled).map(toPublic);
return NextResponse.json({ providers: visible }); return NextResponse.json({ providers: visible });
} catch (error) { } 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: [] }); return NextResponse.json({ providers: [] });
} }
} }

View file

@ -5,7 +5,7 @@ import { defaultModelForProviderType, resolveTtsModelForProvider, resolveTtsProv
import { resolveVoices } from '@/lib/server/tts/voice-resolution'; import { resolveVoices } from '@/lib/server/tts/voice-resolution';
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials'; import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; 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) { export async function GET(req: NextRequest) {
try { try {
@ -62,7 +62,10 @@ export async function GET(req: NextRequest) {
}); });
return NextResponse.json({ voices }); return NextResponse.json({ voices });
} catch (error) { } 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 providerRef = req.headers.get('x-tts-provider') || 'openai';
const model = req.headers.get('x-tts-model') || 'tts-1'; const model = req.headers.get('x-tts-model') || 'tts-1';
const provider = isBuiltInTtsProviderId(providerRef) ? providerRef : 'openai'; 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 { audiobooks, documents, userDocumentProgress, userPreferences } from '@/db/schema';
import { count, eq, ne } from 'drizzle-orm'; import { count, eq, ne } from 'drizzle-orm';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; 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> { async function checkClaimMigrationReadiness(): Promise<NextResponse | null> {
const [legacyRows] = await db const [legacyRows] = await db
@ -57,7 +57,10 @@ export async function GET(req: NextRequest) {
const counts = await getClaimableCounts(unclaimedUserId); const counts = await getClaimableCounts(unclaimedUserId);
return NextResponse.json({ success: true, ...counts }); return NextResponse.json({ success: true, ...counts });
} catch (error) { } 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 }); return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
} }
} }
@ -84,7 +87,10 @@ export async function POST(req: NextRequest) {
}); });
} catch (error) { } 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 }); 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 { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { nowTimestampMs } from '@/lib/shared/timestamps'; import { nowTimestampMs } from '@/lib/shared/timestamps';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -78,7 +78,12 @@ export async function GET(req: NextRequest) {
archive.on('warning', (warning) => { archive.on('warning', (warning) => {
if ((warning as NodeJS.ErrnoException).code !== 'ENOENT') { 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(); await archive.finalize();
} }
} catch (error) { } catch (error) {
serverLogger.error({ err: error }, 'Export generation failed:'); serverLogger.error({
event: 'user.export.generate.failed',
error: errorToLog(error),
}, 'Export generation failed');
archive.abort(); archive.abort();
output.destroy(error instanceof Error ? error : new Error('Failed to generate export archive')); output.destroy(error instanceof Error ? error : new Error('Failed to generate export archive'));
} finally { } finally {

View file

@ -5,7 +5,7 @@ import { userPreferences } from '@/db/schema';
import { normalizeVersion, shouldOpenChangelogForVersionChange } from '@/lib/shared/changelog'; import { normalizeVersion, shouldOpenChangelogForVersionChange } from '@/lib/shared/changelog';
import { nowTimestampMs } from '@/lib/shared/timestamps'; import { nowTimestampMs } from '@/lib/shared/timestamps';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { import {
deserializeUserPreferencesPayload, deserializeUserPreferencesPayload,
extractUserPreferencesMeta, extractUserPreferencesMeta,
@ -81,7 +81,10 @@ export async function POST(req: NextRequest) {
lastSeenVersion, lastSeenVersion,
}); });
} catch (error) { } 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 }); 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 { listAdminProviders } from '@/lib/server/admin/providers';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { import {
deserializeUserPreferencesPayload, deserializeUserPreferencesPayload,
extractUserPreferencesMeta, extractUserPreferencesMeta,
@ -242,7 +242,10 @@ export async function GET(req: NextRequest) {
hasStoredPreferences: Boolean(row), hasStoredPreferences: Boolean(row),
}); });
} catch (error) { } 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 }); return NextResponse.json({ error: 'Failed to load user preferences' }, { status: 500 });
} }
} }
@ -316,7 +319,10 @@ export async function PUT(req: NextRequest) {
applied: true, applied: true,
}); });
} catch (error) { } 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 }); 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 { isValidDocumentId } from '@/lib/server/documents/blobstore';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; 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'; export const dynamic = 'force-dynamic';
@ -63,7 +63,10 @@ export async function GET(req: NextRequest) {
}, },
}); });
} catch (error) { } 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 }); return NextResponse.json({ error: 'Failed to load user progress' }, { status: 500 });
} }
} }
@ -173,7 +176,10 @@ export async function PUT(req: NextRequest) {
applied: true, applied: true,
}); });
} catch (error) { } 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 }); return NextResponse.json({ error: 'Failed to update user progress' }, { status: 500 });
} }
} }

View file

@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db } from '@/db'; 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 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. // 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)); await db.update(user).set({ isAdmin: shouldBeAdmin }).where(eq(user.id, userId));
return shouldBeAdmin; return shouldBeAdmin;
} catch (error) { } 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; return currentIsAdmin;
} }
} }

View file

@ -3,7 +3,7 @@ import { adminProviders, adminSettings } from '@/db/schema';
import { encryptSecret, apiKeyLast4 } from '@/lib/server/crypto/secrets'; import { encryptSecret, apiKeyLast4 } from '@/lib/server/crypto/secrets';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { import {
RUNTIME_CONFIG_SCHEMA, RUNTIME_CONFIG_SCHEMA,
seedRuntimeConfigFromEnv, seedRuntimeConfigFromEnv,
@ -31,7 +31,11 @@ let seedPromise: Promise<void> | null = null;
export async function ensureAdminSeed(): Promise<void> { export async function ensureAdminSeed(): Promise<void> {
if (!seedPromise) { if (!seedPromise) {
seedPromise = runSeed().catch((error) => { 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). // Reset so a subsequent call can retry (e.g. once migrations run).
seedPromise = null; seedPromise = null;
throw error; throw error;
@ -59,7 +63,11 @@ async function seedDefaultAdminProvider(): Promise<void> {
try { try {
existing = await db.select({ id: adminProviders.id }).from(adminProviders).limit(1); existing = await db.select({ id: adminProviders.id }).from(adminProviders).limit(1);
} catch (error) { } 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; return;
} }
if (existing.length > 0) return; if (existing.length > 0) return;
@ -86,14 +94,24 @@ async function seedDefaultAdminProvider(): Promise<void> {
updatedAt: now, updatedAt: now,
}) })
.onConflictDoNothing({ target: adminSettings.key }); .onConflictDoNothing({ target: adminSettings.key });
serverLogger.warn( serverLogger.warn({
'[admin-seed] API_KEY present but AUTH_SECRET missing; defaulting restrictUserApiKeys=false so BYOK remains available', 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) { } 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; return;
} }
@ -112,9 +130,17 @@ async function seedDefaultAdminProvider(): Promise<void> {
createdAt: now, createdAt: now,
updatedAt: 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) { } 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) { } 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) .delete(adminSettings)
.where(eq(adminSettings.key, 'defaultTtsModel')); .where(eq(adminSettings.key, 'defaultTtsModel'));
} catch (error) { } 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 { db } from '@/db';
import { adminProviders, adminSettings } from '@/db/schema'; import { adminProviders, adminSettings } from '@/db/schema';
import { isAuthEnabled } from '@/lib/server/auth/config'; 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. * 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); .limit(1);
return rows[0]?.slug; return rows[0]?.slug;
} catch (error) { } 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; return undefined;
} }
} }
@ -142,7 +146,11 @@ async function readAllRows(): Promise<Map<string, { value: unknown; source: stri
} }
return out; return out;
} catch (error) { } 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(); return new Map();
} }
} }
@ -298,7 +306,12 @@ export async function seedRuntimeConfigFromEnv(): Promise<{ seeded: RuntimeConfi
.onConflictDoNothing({ target: adminSettings.key }); .onConflictDoNothing({ target: adminSettings.key });
seeded.push(key); seeded.push(key);
} catch (error) { } 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 }; return { seeded };

View file

@ -11,7 +11,7 @@ import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { assertUserSignupAllowed } from '@/lib/server/auth/signup-policy'; import { assertUserSignupAllowed } from '@/lib/server/auth/signup-policy';
import * as authSchemaSqlite from "@/db/schema_auth_sqlite"; import * as authSchemaSqlite from "@/db/schema_auth_sqlite";
import * as authSchemaPostgres from "@/db/schema_auth_postgres"; 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 // Heavy modules (S3 SDK, blobstore, rate-limiter, claim-data) are loaded
// lazily via dynamic import() inside the beforeDelete / onLinkAccount // lazily via dynamic import() inside the beforeDelete / onLinkAccount
@ -72,7 +72,10 @@ const createAuth = () => betterAuth({
requireEmailVerification: false, // Set to true in production requireEmailVerification: false, // Set to true in production
async sendResetPassword(data) { async sendResetPassword(data) {
// Send an email to the user with a link to reset their password // 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: { user: {
@ -91,7 +94,13 @@ const createAuth = () => betterAuth({
const { deleteUserStorageData } = await import('@/lib/server/user/data-cleanup'); const { deleteUserStorageData } = await import('@/lib/server/user/data-cleanup');
await deleteUserStorageData(user.id, null); await deleteUserStorageData(user.id, null);
} catch (error) { } 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. // Don't throw allow the user deletion to proceed even if S3 cleanup fails.
// Orphaned blobs are preferable to a blocked account deletion. // Orphaned blobs are preferable to a blocked account deletion.
} }
@ -147,10 +156,11 @@ const createAuth = () => betterAuth({
try { try {
// Log when anonymous user links to a real account // Log when anonymous user links to a real account
serverLogger.info({ serverLogger.info({
anonymousUserId: anonymousUser.user.id, event: 'auth.link_account.started',
newUserId: newUser.user.id, anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserEmail: newUser.user.email, newUserIdHash: hashForLog(newUser.user.id),
}, "Anonymous user linked to account:"); newUserEmailHash: hashForLog(newUser.user.email),
}, 'Anonymous user linked to account');
// Lazy-load heavy modules only when account linking actually happens // Lazy-load heavy modules only when account linking actually happens
const [{ rateLimiter }, claimData] = await Promise.all([ 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 // Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user
try { try {
await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id); 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) { } 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 // Don't throw here to prevent blocking the account linking process
} }
@ -171,10 +192,22 @@ const createAuth = () => betterAuth({
try { try {
const transferred = await claimData.transferUserAudiobooks(anonymousUser.user.id, newUser.user.id); const transferred = await claimData.transferUserAudiobooks(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) { 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) { } 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 // Don't throw here to prevent blocking the account linking process
} }
@ -182,10 +215,22 @@ const createAuth = () => betterAuth({
try { try {
const transferred = await claimData.transferUserDocuments(anonymousUser.user.id, newUser.user.id); const transferred = await claimData.transferUserDocuments(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) { 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) { } 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 // Don't throw here to prevent blocking the account linking process
} }
@ -193,10 +238,22 @@ const createAuth = () => betterAuth({
try { try {
const transferred = await claimData.transferUserPreferences(anonymousUser.user.id, newUser.user.id); const transferred = await claimData.transferUserPreferences(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) { 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) { } 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 // Don't throw here to prevent blocking the account linking process
} }
@ -204,14 +261,29 @@ const createAuth = () => betterAuth({
try { try {
const transferred = await claimData.transferUserProgress(anonymousUser.user.id, newUser.user.id); const transferred = await claimData.transferUserProgress(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) { 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) { } 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 // Don't throw here to prevent blocking the account linking process
} }
} catch (error) { } 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 // Don't throw here to prevent blocking the account linking process
} }
// Note: Anonymous user will be automatically deleted after this callback completes // 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 { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker';
import type { WorkerOperationState } from '@openreader/compute-core/api-contracts'; 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; const WORKER_OP_REQUEST_TIMEOUT_MS = 2_500;
@ -15,9 +15,11 @@ export async function fetchWorkerOperationState<Result>(
cfg = getWorkerClientConfigFromEnv(); cfg = getWorkerClientConfigFromEnv();
} catch (error) { } catch (error) {
serverLogger.warn({ serverLogger.warn({
event: 'compute.worker_op_state.config.invalid',
opId: normalized, opId: normalized,
error: error instanceof Error ? error.message : String(error), degraded: true,
}, '[worker-op-state] worker client env missing/invalid'); error: errorToLog(error),
}, 'Worker client env missing/invalid');
return null; return null;
} }
@ -38,25 +40,31 @@ export async function fetchWorkerOperationState<Result>(
if (!res.ok) { if (!res.ok) {
const detail = await res.text().catch(() => ''); const detail = await res.text().catch(() => '');
serverLogger.warn({ serverLogger.warn({
event: 'compute.worker_op_state.fetch.failed',
opId: normalized, opId: normalized,
status: res.status, status: res.status,
detail, detail,
}, '[worker-op-state] worker op request failed'); degraded: true,
}, 'Worker op request failed');
return null; return null;
} }
const parsed = await res.json() as WorkerOperationState<Result>; const parsed = await res.json() as WorkerOperationState<Result>;
if (!parsed || typeof parsed !== 'object' || parsed.opId !== normalized) { if (!parsed || typeof parsed !== 'object' || parsed.opId !== normalized) {
serverLogger.warn({ serverLogger.warn({
event: 'compute.worker_op_state.response.invalid',
opId: normalized, opId: normalized,
}, '[worker-op-state] worker op response invalid'); degraded: true,
}, 'Worker op response invalid');
return null; return null;
} }
return parsed; return parsed;
} catch (error) { } catch (error) {
serverLogger.warn({ serverLogger.warn({
event: 'compute.worker_op_state.fetch.error',
opId: normalized, opId: normalized,
error: error instanceof Error ? error.message : String(error), degraded: true,
}, '[worker-op-state] worker op request threw'); error: errorToLog(error),
}, 'Worker op request threw');
return null; return null;
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);

View file

@ -25,7 +25,6 @@ class WorkerHttpError extends Error {
} }
const DEFAULT_RETRIES = 2; const DEFAULT_RETRIES = 2;
const LOG_PREFIX = '[compute-worker-client]';
const MAX_LOG_DETAIL_CHARS = 600; const MAX_LOG_DETAIL_CHARS = 600;
const LOG_EVENTS = new Set([ const LOG_EVENTS = new Set([
'align.request.failed', '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 { function logWorker(level: WorkerLogLevel, event: string, fields: Record<string, unknown>): void {
if (!LOG_EVENTS.has(event)) return; if (!LOG_EVENTS.has(event)) return;
const payload = {
ts: new Date().toISOString(),
event,
...fields,
};
const line = `${LOG_PREFIX} ${JSON.stringify(payload)}`;
if (level === 'error') { if (level === 'error') {
serverLogger.error(line); serverLogger.error({
event: `compute.worker_client.${event}`,
operation: 'compute_worker_client',
...fields,
}, `Worker client ${event}`);
return; return;
} }
if (level === 'warn') { if (level === 'warn') {
serverLogger.warn(line); serverLogger.warn({
event: `compute.worker_client.${event}`,
operation: 'compute_worker_client',
...fields,
}, `Worker client ${event}`);
return; 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> { 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 { and, eq, inArray, lt, or, sql } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { documentPreviews } from '@/db/schema'; import { documentPreviews } from '@/db/schema';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { import {
DOCUMENT_PREVIEW_CONTENT_TYPE, DOCUMENT_PREVIEW_CONTENT_TYPE,
DOCUMENT_PREVIEW_VARIANT, DOCUMENT_PREVIEW_VARIANT,
@ -375,7 +375,12 @@ export async function ensureDocumentPreview(doc: PreviewSourceDocument, namespac
eTag: head.eTag, eTag: head.eTag,
}); });
} catch (error) { } 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); 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 { db } from '@/db';
import { documents } from '@/db/schema'; import { documents } from '@/db/schema';
import { documentKey, putParsedDocumentBlob } from '@/lib/server/documents/blobstore'; import { documentKey, putParsedDocumentBlob } from '@/lib/server/documents/blobstore';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { import {
parseDocumentParseState, parseDocumentParseState,
stringifyDocumentParseState, stringifyDocumentParseState,
@ -350,17 +350,23 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise<void>
}).then((cleared) => { }).then((cleared) => {
if (cleared.warning) { if (cleared.warning) {
serverLogger.warn({ serverLogger.warn({
event: 'documents.parse.cache_invalidation.warning',
degraded: true,
step: 'clear_tts_segment_cache',
documentId: input.documentId, documentId: input.documentId,
userId, userId,
warning: cleared.warning, warning: cleared.warning,
}, '[parsePdfJob] cache invalidation warning'); }, 'Parse cache invalidation warning');
} }
}).catch((cacheError) => { }).catch((cacheError) => {
serverLogger.warn({ serverLogger.warn({
event: 'documents.parse.cache_invalidation.failed',
degraded: true,
step: 'clear_tts_segment_cache',
documentId: input.documentId, documentId: input.documentId,
userId, userId,
error: cacheError instanceof Error ? cacheError.message : String(cacheError), error: errorToLog(cacheError),
}, '[parsePdfJob] cache invalidation failed'); }, 'Parse cache invalidation failed');
}); });
} }
} catch (error) { } catch (error) {
@ -385,18 +391,20 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise<void>
}); });
} catch (statusError) { } catch (statusError) {
serverLogger.error({ serverLogger.error({
event: 'documents.parse.status_write.failed',
documentId: input.documentId, documentId: input.documentId,
parseStatus, parseStatus,
error: statusError instanceof Error ? statusError.message : String(statusError), error: errorToLog(statusError),
}, '[parsePdfJob] failed to write parse status'); }, 'Failed to write parse status');
} }
serverLogger.error({ serverLogger.error({
event: 'documents.parse.job.failed',
documentId: input.documentId, documentId: input.documentId,
parseStatus, parseStatus,
error: message, error: message,
...(stack ? { stack } : {}), ...(stack ? { stack } : {}),
...(cause ? { cause: String(cause) } : {}), ...(cause ? { cause: String(cause) } : {}),
}, '[parsePdfJob] failed'); }, 'Parse job failed');
} finally { } finally {
running.delete(key); running.delete(key);
} }
@ -406,6 +414,10 @@ export function enqueueParsePdfJob(input: UserPdfLayoutJobRequest): void {
Promise.resolve() Promise.resolve()
.then(() => parsePdfJob(input)) .then(() => parsePdfJob(input))
.catch((error) => { .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'; import pinoPretty from 'pino-pretty';
export type ServerLogger = Logger; 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_FORMAT = process.env.LOG_FORMAT?.trim().toLowerCase() || 'pretty';
const LOG_LEVEL = process.env.LOG_LEVEL?.trim() || 'info'; 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) }; 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 { export function hashForLog(value: string | null | undefined): string | null {
const normalized = typeof value === 'string' ? value.trim() : ''; const normalized = typeof value === 'string' ? value.trim() : '';
if (!normalized) return null; if (!normalized) return null;

View file

@ -11,7 +11,13 @@ function readPositiveIntEnv(name: string, fallback: number): number {
const parsed = Number(raw); const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) { 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; return fallback;
} }

View file

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

View file

@ -1,5 +1,5 @@
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { import {
resolveProviderModels, resolveProviderModels,
type ReplicateVoiceInputKey, type ReplicateVoiceInputKey,
@ -193,7 +193,10 @@ async function fetchReplicateOpenApiSchema(apiKey: string, model: string): Promi
if (error instanceof DOMException && error.name === 'AbortError') { if (error instanceof DOMException && error.name === 'AbortError') {
return null; 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; return null;
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
@ -284,7 +287,10 @@ async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
if (error instanceof DOMException && error.name === 'AbortError') { if (error instanceof DOMException && error.name === 'AbortError') {
return []; 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 []; return [];
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
@ -316,7 +322,11 @@ async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise
? data.voices ? data.voices
: null; : null;
} catch { } 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; return null;
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);

View file

@ -13,7 +13,7 @@ import { deleteDocumentPreviewArtifacts } from '@/lib/server/documents/previews-
import { deleteDocumentPreviewRows } from '@/lib/server/documents/previews'; import { deleteDocumentPreviewRows } from '@/lib/server/documents/previews';
import { audiobookPrefix, deleteAudiobookPrefix } from '@/lib/server/audiobooks/blobstore'; import { audiobookPrefix, deleteAudiobookPrefix } from '@/lib/server/audiobooks/blobstore';
import { deleteTtsSegmentPrefix } from '@/lib/server/tts/segments-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 DocumentRow = { id: string };
type AudiobookRow = { id: string }; type AudiobookRow = { id: string };
@ -50,13 +50,27 @@ export async function deleteUserStorageData(
await deleteDocumentBlob(doc.id, namespace); await deleteDocumentBlob(doc.id, namespace);
docsDeleted++; docsDeleted++;
} catch (error) { } 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 { try {
await deleteDocumentPreviewArtifacts(doc.id, namespace); await deleteDocumentPreviewArtifacts(doc.id, namespace);
} catch (error) { } 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 { try {
await deleteDocumentPreviewRows(doc.id, namespace); await deleteDocumentPreviewRows(doc.id, namespace);
} catch (error) { } 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); await deleteAudiobookPrefix(prefix);
booksDeleted++; booksDeleted++;
} catch (error) { } 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(ttsPrefixV1);
segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV2); segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV2);
} catch (error) { } 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) { if (docsDeleted > 0 || booksDeleted > 0 || segmentsDeleted > 0) {
serverLogger.info( serverLogger.info({
`[user-data-cleanup] Cleaned up S3 data for user ${userId}: ` + event: 'user.data_cleanup.completed',
`${docsDeleted}/${userDocs.length} document(s), ` + userIdHash: hashForLog(userId),
`${booksDeleted}/${userBooks.length} audiobook(s), ` + docsDeleted,
`${segmentsDeleted} tts segment object(s)`, totalDocs: userDocs.length,
); booksDeleted,
totalBooks: userBooks.length,
segmentsDeleted,
}, 'Completed user storage cleanup');
} }
} }