"use node"; import { api, internal } from "./_generated/api"; import { internalAction } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; import type { ActionCtx } from "./_generated/server"; import { v } from "convex/values"; import { classifyPageSpeedError, fetchPageSpeedResult, normalizePageSpeedResult, type PageSpeedErrorType, } from "../lib/pagespeed-insights"; const STRATEGIES = ["mobile", "desktop"] as const; export const MAX_RAW_PAGESPEED_BYTES = 1_000_000; const RAW_PAGESPEED_BYTES_SUMMARY = "PageSpeed-Rohdaten sind groesser als das interne Speicherlimit."; const DEFAULT_PAGESPEED_TIMEOUT_MS = 60_000; const MIN_PAGESPEED_TIMEOUT_MS = 10_000; const MAX_PAGESPEED_TIMEOUT_MS = 120_000; function toPersistedPageSpeedNormalizedResult( normalized: ReturnType, ) { return { ...(normalized.scores ? { scores: normalized.scores } : {}), metrics: normalized.metrics, opportunities: normalized.opportunities, implications: normalized.implications, }; } function parsePageSpeedTimeoutMs(raw: string | undefined): number { if (!raw) { return DEFAULT_PAGESPEED_TIMEOUT_MS; } const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed)) { return DEFAULT_PAGESPEED_TIMEOUT_MS; } return Math.min( Math.max(parsed, MIN_PAGESPEED_TIMEOUT_MS), MAX_PAGESPEED_TIMEOUT_MS, ); } function resolvePageSpeedTimeoutMs() { return parsePageSpeedTimeoutMs(process.env.PAGESPEED_TIMEOUT_MS); } function isPageSpeedErrorType(value: unknown): value is PageSpeedErrorType { return ( value === "quota" || value === "timeout" || value === "unavailable" || value === "invalid_url" || value === "api_error" || value === "unknown" ); } function sanitizeValue(value: string, secret?: string | null) { if (!secret || !value) { return value; } const escapedSecret = secret.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return value.replace(new RegExp(escapedSecret, "g"), "[REDACTED]"); } function classifyPageSpeedFailure(input: unknown, apiKey?: string | null) { const directType = typeof input === "object" && input !== null && "errorType" in input && (input as { errorType?: unknown }).errorType; const normalizedType = isPageSpeedErrorType(directType) ? directType : null; if (normalizedType) { const message = input instanceof Error && input.message ? input.message : typeof input === "string" ? input : "PageSpeed-Analyse fehlgeschlagen."; return { errorType: normalizedType, errorSummary: sanitizeValue(message, apiKey), }; } const classified = classifyPageSpeedError({ error: input, }); const errorSummary = sanitizeValue(classified.message, apiKey); return { errorType: classified.errorType, errorSummary, }; } type StartedPageSpeedAudit = { lead: { _id: Id<"leads">; websiteUrl: string; }; auditId?: Id<"audits">; }; async function queueAuditGenerationAfterPageSpeed( ctx: ActionCtx, runId: Id<"agentRuns">, started: StartedPageSpeedAudit, ) { try { await ctx.runMutation(internal.auditGeneration.queueLeadAuditGeneration, { leadId: started.lead._id, ...(started.auditId ? { auditId: started.auditId } : {}), parentRunId: runId, }); } catch (auditQueueError) { await ctx.runMutation(api.runs.appendEvent, { runId, level: "warning", message: "Audit-Generierung konnte nicht in die Warteschlange gesetzt werden.", details: [ { label: "Lead", value: started.lead._id }, { label: "Fehler", value: auditQueueError instanceof Error ? auditQueueError.message : String(auditQueueError), source: "audit_generation_queue", }, ], }); } } export const processPageSpeedAudit = internalAction({ args: { runId: v.id("agentRuns"), }, handler: async (ctx, args) => { const apiKeyRaw = process.env.PAGESPEED_API_KEY?.trim(); const apiKey = apiKeyRaw ? apiKeyRaw : undefined; let started: StartedPageSpeedAudit | null = null; try { started = await ctx.runMutation(internal.pageSpeed.startPageSpeedAuditRun, { runId: args.runId, }); } catch (error) { const { errorSummary } = classifyPageSpeedFailure(error, apiKeyRaw); await ctx.runMutation(internal.pageSpeed.finishPageSpeedAuditRun, { runId: args.runId, status: "failed", errors: 1, errorSummary, }); await ctx.runMutation(api.runs.appendEvent, { runId: args.runId, level: "error", message: "PageSpeed-Analyse fehlgeschlagen.", details: [{ label: "Fehler", value: errorSummary }], }); return null; } if (!started) { return null; } const sourceUrl = started.lead.websiteUrl; const timeoutMs = resolvePageSpeedTimeoutMs(); let failedStrategies = 0; let succeededStrategies = 0; try { for (const strategy of STRATEGIES) { const fetchedAt = Date.now(); try { const raw = await fetchPageSpeedResult({ url: sourceUrl, strategy, apiKey, timeoutMs, }); const rawJson = JSON.stringify(raw) ?? "null"; const rawJsonBytes = new TextEncoder().encode(rawJson).byteLength; if (rawJsonBytes > MAX_RAW_PAGESPEED_BYTES) { failedStrategies += 1; await ctx.runMutation(internal.pageSpeed.persistPageSpeedResult, { leadId: started.lead._id, ...(started.auditId ? { auditId: started.auditId } : {}), runId: args.runId, strategy, status: "failed", sourceUrl, errorType: "api_error", errorSummary: RAW_PAGESPEED_BYTES_SUMMARY, fetchedAt, }); await ctx.runMutation(api.runs.appendEvent, { runId: args.runId, level: "warning", message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`, details: [ { label: "Strategie", value: strategy }, { label: "Fehler", value: RAW_PAGESPEED_BYTES_SUMMARY, }, ], }); continue; } const rawStorageId = await ctx.storage.store( new Blob([rawJson], { type: "application/json" }), ); const normalized = normalizePageSpeedResult({ strategy, sourceUrl, raw, }); await ctx.runMutation(internal.pageSpeed.persistPageSpeedResult, { leadId: started.lead._id, ...(started.auditId ? { auditId: started.auditId } : {}), runId: args.runId, strategy, status: "succeeded", sourceUrl, finalUrl: normalized.finalUrl, rawStorageId, fetchedAt, normalized: toPersistedPageSpeedNormalizedResult(normalized), }); await ctx.runMutation(api.runs.appendEvent, { runId: args.runId, level: "info", message: `PageSpeed-Analyse für ${strategy} abgeschlossen.`, details: [{ label: "Strategie", value: strategy }], }); succeededStrategies += 1; } catch (error) { const { errorType, errorSummary } = classifyPageSpeedFailure( error, apiKeyRaw, ); failedStrategies += 1; await ctx.runMutation(internal.pageSpeed.persistPageSpeedResult, { leadId: started.lead._id, ...(started.auditId ? { auditId: started.auditId } : {}), runId: args.runId, strategy, status: "failed", sourceUrl, errorType, errorSummary, fetchedAt, }); await ctx.runMutation(api.runs.appendEvent, { runId: args.runId, level: "warning", message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`, details: [ { label: "Strategie", value: strategy }, { label: "Fehler", value: errorSummary }, ], }); } } const status = succeededStrategies > 0 ? "succeeded" : "failed"; const errors = failedStrategies; await ctx.runMutation(internal.pageSpeed.finishPageSpeedAuditRun, { runId: args.runId, status, errors, errorSummary: status === "failed" && errors > 0 ? "Ein oder mehrere PageSpeed-Strategien konnten nicht ausgeführt werden." : undefined, }); await queueAuditGenerationAfterPageSpeed(ctx, args.runId, started); return args.runId; } catch (error) { const { errorSummary } = classifyPageSpeedFailure(error, apiKeyRaw); await ctx.runMutation(internal.pageSpeed.finishPageSpeedAuditRun, { runId: args.runId, status: "failed", errors: Math.max(1, failedStrategies), errorSummary, }); await ctx.runMutation(api.runs.appendEvent, { runId: args.runId, level: "error", message: "PageSpeed-Analyse fehlgeschlagen.", details: [{ label: "Fehler", value: errorSummary, source: "pagespeed_action" }], }); await queueAuditGenerationAfterPageSpeed(ctx, args.runId, started); return null; } }, });