diff --git a/deploy/compute.tf b/deploy/compute.tf index 4ef3b18d..09ed119d 100644 --- a/deploy/compute.tf +++ b/deploy/compute.tf @@ -438,7 +438,7 @@ resource "aws_lambda_function" "content_sanitizer" { handler = "content-sanitizer.handler" runtime = "nodejs24.x" memory_size = 128 - timeout = 10 + timeout = 60 publish = true filename = data.archive_file.lambda_stub.output_path diff --git a/src/api/app.ts b/src/api/app.ts index 225e8837..bfd3309f 100644 --- a/src/api/app.ts +++ b/src/api/app.ts @@ -219,15 +219,23 @@ export function createApp({ threadDb, resourceDb, accountDb, exchangesDb, auditD ? await c.req.raw.clone().text() : undefined; - await next(); + // Fires on its own clock at the 25s mark regardless of whether next() ever settles — + // a stuck downstream handler would never reach code after `await next()`, since the + // await itself never returns. This is a plain timer, not a check after the await, + // precisely so it still fires even if the request hangs all the way to Lambda timeout. + const slowRequestTimer = setTimeout(() => { + logger.track("Request exceeded 25s — at risk of Lambda timeout.", { code: "api.slow_request", method: c.req.method, path: c.req.path, elapsedMs: Date.now() - start }); + }, 25_000); + + try { + await next(); + } finally { + clearTimeout(slowRequestTimer); + } const elapsed = Date.now() - start; const status = c.res.status; - if (elapsed > 25_000) { - logger.track("Request exceeded 25s — at risk of Lambda timeout.", { code: "api.slow_request", method: c.req.method, path: c.req.path, status, elapsedMs: elapsed }); - } - const logData: Record = { code: "api.request", method: c.req.method, diff --git a/src/isolated/content-sanitizer.ts b/src/isolated/content-sanitizer.ts index dba8d717..2013e634 100644 --- a/src/isolated/content-sanitizer.ts +++ b/src/isolated/content-sanitizer.ts @@ -2,6 +2,12 @@ import { simpleParser } from "mailparser"; import { Window } from "happy-dom"; import { sanitizeHtml } from "./html-sanitizer.js"; import { extractAssets, type ExtractedAsset } from "./asset-extractor.js"; +import type { Logger } from "../logger.js"; + +// Lambda timeout is 60s (see deploy/compute.tf). Emitting a TRACK log past this +// threshold surfaces invocations that are close to timing out, before they start +// actually failing — mirrors the api.slow_request pattern in src/api/app.ts. +const SLOW_INVOCATION_THRESHOLD_MS = 50_000; // --------------------------------------------------------------------------- // Types @@ -155,25 +161,39 @@ async function uploadViaPresignedPost( // ContentSanitizeError instead of an opaque Lambda "Unhandled" FunctionError — the // processor can then surface the real message/type instead of just "Unhandled". export async function handler(event: ContentSanitizeRequest): Promise { + const start = Date.now(); + let logger: Logger | undefined; + if (event.invocationId) { + const { RequestLogger } = await import("../logger.js"); + const requestLogger = new RequestLogger(); + requestLogger.startInvocation(event.invocationId); + requestLogger.info("content-sanitizer.invoked", { code: "content_sanitizer.invoked", invocationId: event.invocationId, accountId: event.accountId }); + logger = requestLogger; + } + + // Fires on its own clock at the 50s mark regardless of whether processEmail ever + // settles — a stuck fetch/parse would never reach a `finally` after `await`, since + // the await itself never returns. This is a plain timer, not a Promise.race, precisely + // so it still fires even if the awaited work hangs all the way to the Lambda timeout. + const slowInvocationTimer = setTimeout(() => { + logger?.track("Content sanitizer invocation exceeded 50s — at risk of Lambda timeout.", { code: "content_sanitizer.slow_invocation", elapsedMs: Date.now() - start, accountId: event.accountId }); + }, SLOW_INVOCATION_THRESHOLD_MS); + try { - return await processEmail(event); + return await processEmail(event, logger); } catch (e) { return { success: false, error: { message: e instanceof Error ? e.message : String(e), type: "internal_error" }, }; + } finally { + clearTimeout(slowInvocationTimer); } } -async function processEmail(event: ContentSanitizeRequest): Promise { - if (event.invocationId) { - const { RequestLogger } = await import("../logger.js"); - const logger = new RequestLogger(); - logger.startInvocation(event.invocationId); - logger.info("content-sanitizer.invoked", { code: "content_sanitizer.invoked", invocationId: event.invocationId, accountId: event.accountId }); - } - +async function processEmail(event: ContentSanitizeRequest, logger?: Logger): Promise { // 1. Fetch raw MIME via presignedGetUrl + logger?.trackPoint("fetch_mime_start"); let rawMime: Buffer; try { const response = await fetch(event.presignedGetUrl); @@ -184,6 +204,7 @@ async function processEmail(event: ContentSanitizeRequest): Promise]+)/g, (_, id: string) => cidMap[id] ?? ""); + logger?.trackPoint("html_sanitized", { htmlLength: htmlInput.length }); // Extract links from raw HTML using a DOM parser (before sanitization strips tracking pixels etc.) const linkDoc = new Window({ url: "about:blank" }).document; @@ -291,6 +317,7 @@ async function processEmail(event: ContentSanitizeRequest): Promise