Files
webdev-pipeline/convex/pageSpeedAction.ts
2026-06-05 14:14:07 +02:00

324 lines
9.4 KiB
TypeScript

"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<typeof normalizePageSpeedResult>,
) {
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;
}
},
});