From 3c7e704b4a2b9815d7e2f486b17be9af49255a53 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:22:44 -0400 Subject: [PATCH] Triage active PostHog errors: validator noise, workflow output cap, Autumn retries, deploy-reset noise (#361) --- .../rank-tracking/AddKeywordsPanel.tsx | 7 ++++ src/middleware/errorHandling.ts | 28 +++++++++++-- src/server/billing/autumn.ts | 30 ++++++++++++++ src/server/billing/subscription.ts | 40 +++++++++++-------- src/server/lib/dataforseo/client.test.ts | 7 ++++ src/server/workflows/SiteAuditWorkflow.ts | 20 +++++++--- .../workflows/siteAuditWorkflowPhases.ts | 18 ++++++++- src/shared/rank-tracking.ts | 3 ++ src/types/schemas/rank-tracking.ts | 6 ++- 9 files changed, 131 insertions(+), 28 deletions(-) diff --git a/src/client/features/rank-tracking/AddKeywordsPanel.tsx b/src/client/features/rank-tracking/AddKeywordsPanel.tsx index 8264aaa..bd9229f 100644 --- a/src/client/features/rank-tracking/AddKeywordsPanel.tsx +++ b/src/client/features/rank-tracking/AddKeywordsPanel.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { toast } from "sonner"; import { useMutation } from "@tanstack/react-query"; import { addTrackingKeywords } from "@/serverFunctions/rank-tracking"; +import { MAX_TRACKED_KEYWORD_LENGTH } from "@/shared/rank-tracking"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { Loader2 } from "lucide-react"; @@ -46,6 +47,12 @@ export function AddKeywordsPanel({ .split("\n") .map((l) => l.trim()) .filter(Boolean); + if (lines.some((l) => l.length > MAX_TRACKED_KEYWORD_LENGTH)) { + toast.error( + `Keywords must be ${MAX_TRACKED_KEYWORD_LENGTH} characters or fewer.`, + ); + return; + } if (lines.length > 0) mutation.mutate(lines); }} disabled={isPending || !keywordInput.trim()} diff --git a/src/middleware/errorHandling.ts b/src/middleware/errorHandling.ts index 74a3d9c..c4ed8d2 100644 --- a/src/middleware/errorHandling.ts +++ b/src/middleware/errorHandling.ts @@ -2,9 +2,29 @@ import { createMiddleware } from "@tanstack/react-start"; import { getRequest } from "@tanstack/react-start/server"; import { waitUntil } from "cloudflare:workers"; import { shouldCaptureAppErrorCode } from "@/shared/error-codes"; -import { asAppError, toClientError } from "@/server/lib/errors"; +import { AppError, asAppError, toClientError } from "@/server/lib/errors"; import { captureServerError } from "@/server/lib/posthog"; +// TanStack's serverFn validator throws a plain Error whose message is the +// JSON-serialized standard-schema issue list. Treat those as input validation, +// not server faults. +function isValidatorError(error: Error): boolean { + if (!error.message.startsWith("[")) return false; + try { + const issues: unknown = JSON.parse(error.message); + if (!Array.isArray(issues) || issues.length === 0) return false; + return issues.every( + (issue: unknown) => + typeof issue === "object" && + issue !== null && + "message" in issue && + typeof issue.message === "string", + ); + } catch { + return false; + } +} + export const errorHandlingMiddleware = createMiddleware({ type: "function", }).server(async (c) => { @@ -17,7 +37,9 @@ export const errorHandlingMiddleware = createMiddleware({ throw new Error("INTERNAL_ERROR", { cause: error }); } - const appError = asAppError(error); + const appError = isValidatorError(error) + ? new AppError("VALIDATION_ERROR") + : asAppError(error); if (shouldCaptureAppErrorCode(appError?.code)) { const request = getRequest(); @@ -34,6 +56,6 @@ export const errorHandlingMiddleware = createMiddleware({ ); } - throw toClientError(error); + throw toClientError(appError ?? error); } }); diff --git a/src/server/billing/autumn.ts b/src/server/billing/autumn.ts index ad79d4c..3785f85 100644 --- a/src/server/billing/autumn.ts +++ b/src/server/billing/autumn.ts @@ -3,4 +3,34 @@ import { getRequiredEnvValue } from "@/server/lib/runtime-env"; export const autumn = new Autumn({ secretKey: () => getRequiredEnvValue("AUTUMN_SECRET_KEY"), + // Retries 429/500/502/503/504 (per-operation retryCodes) plus connection + // errors. Cloudflare 52x statuses are not in the SDK's retry list, so those + // still surface immediately. + retryConfig: { + strategy: "backoff", + backoff: { + initialInterval: 250, + maxInterval: 2000, + exponent: 1.5, + maxElapsedTime: 8000, + }, + retryConnectionErrors: true, + }, }); + +// track() has no idempotency key, so replaying a deduction Autumn already +// processed (5xx after a successful write, dropped connection) would +// double-charge. Retry only 429s, which are rejected before processing. +export const AUTUMN_TRACK_RETRY_OPTIONS: Parameters[1] = { + retryCodes: ["429"], + retries: { + strategy: "backoff", + backoff: { + initialInterval: 250, + maxInterval: 2000, + exponent: 1.5, + maxElapsedTime: 8000, + }, + retryConnectionErrors: false, + }, +}; diff --git a/src/server/billing/subscription.ts b/src/server/billing/subscription.ts index 78b0a12..440e165 100644 --- a/src/server/billing/subscription.ts +++ b/src/server/billing/subscription.ts @@ -9,7 +9,7 @@ import { roundUsdForBilling, } from "@/shared/billing"; import type { CreditFeature } from "@/shared/billing-credit-features"; -import { autumn } from "@/server/billing/autumn"; +import { autumn, AUTUMN_TRACK_RETRY_OPTIONS } from "@/server/billing/autumn"; import { captureServerEvent } from "@/server/lib/posthog"; import { AppError } from "@/server/lib/errors"; @@ -127,27 +127,33 @@ export async function trackUsageCreditSpend(args: { }; if (monthlyDeduct > 0) { - await autumn.track({ - customerId: args.customerId, - featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, - value: monthlyDeduct, - properties: { - ...properties, - balanceFeatureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + await autumn.track( + { + customerId: args.customerId, + featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + value: monthlyDeduct, + properties: { + ...properties, + balanceFeatureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + }, }, - }); + AUTUMN_TRACK_RETRY_OPTIONS, + ); } if (topupDeduct > 0) { - await autumn.track({ - customerId: args.customerId, - featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, - value: topupDeduct, - properties: { - ...properties, - balanceFeatureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, + await autumn.track( + { + customerId: args.customerId, + featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, + value: topupDeduct, + properties: { + ...properties, + balanceFeatureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, + }, }, - }); + AUTUMN_TRACK_RETRY_OPTIONS, + ); } await captureServerEvent({ diff --git a/src/server/lib/dataforseo/client.test.ts b/src/server/lib/dataforseo/client.test.ts index 40553bf..322ca7d 100644 --- a/src/server/lib/dataforseo/client.test.ts +++ b/src/server/lib/dataforseo/client.test.ts @@ -31,6 +31,7 @@ vi.mock("@/server/billing/autumn", () => ({ check: checkMock, track: trackMock, }, + AUTUMN_TRACK_RETRY_OPTIONS: {}, })); // Keep the real subscription module (its assertUsageCreditsAvailable calls the @@ -189,6 +190,7 @@ describe("meterDataforseoCall with split balances", () => { featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, value: EXPECTED_CREDITS, }), + expect.anything(), ); }); @@ -207,6 +209,7 @@ describe("meterDataforseoCall with split balances", () => { featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, value: EXPECTED_CREDITS, }), + expect.anything(), ); }); @@ -226,6 +229,7 @@ describe("meterDataforseoCall with split balances", () => { featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, value: monthlyAvailable, }), + expect.anything(), ); expect(trackMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -233,6 +237,7 @@ describe("meterDataforseoCall with split balances", () => { featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, value: EXPECTED_CREDITS - monthlyAvailable, }), + expect.anything(), ); }); @@ -271,6 +276,7 @@ describe("meterDataforseoCall with split balances", () => { featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, value: EXPECTED_CREDITS, }), + expect.anything(), ); }); @@ -316,6 +322,7 @@ describe("meterDataforseoCall with split balances", () => { featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, value: EXPECTED_CREDITS, }), + expect.anything(), ); }); diff --git a/src/server/workflows/SiteAuditWorkflow.ts b/src/server/workflows/SiteAuditWorkflow.ts index 821b716..2ca3924 100644 --- a/src/server/workflows/SiteAuditWorkflow.ts +++ b/src/server/workflows/SiteAuditWorkflow.ts @@ -67,12 +67,20 @@ export class SiteAuditWorkflow extends WorkflowEntrypoint { // Workflow entrypoints run outside the server-function middleware, so // nothing else forwards this throw to PostHog as a $exception. Capture it // here (awaited — Workflows have no ctx.waitUntil) before re-throwing. - await captureServerError(error, { - source: "site_audit_workflow", - audit_id: auditId, - organization_id: billingCustomer.organizationId, - project_id: projectId, - }); + // Deploy-time resets are expected churn, not actionable errors. + const isDeployReset = + error instanceof Error && + error.message.includes( + "Durable Object reset because its code was updated", + ); + if (!isDeployReset) { + await captureServerError(error, { + source: "site_audit_workflow", + audit_id: auditId, + organization_id: billingCustomer.organizationId, + project_id: projectId, + }); + } await pgStep(step, "mark-failed", undefined, async () => { await AuditRepository.failAudit(auditId, event.instanceId); diff --git a/src/server/workflows/siteAuditWorkflowPhases.ts b/src/server/workflows/siteAuditWorkflowPhases.ts index 8197d32..034a481 100644 --- a/src/server/workflows/siteAuditWorkflowPhases.ts +++ b/src/server/workflows/siteAuditWorkflowPhases.ts @@ -19,6 +19,22 @@ import { pgStep } from "@/server/workflows/pgStep"; const LIGHTHOUSE_URL_BATCH_SIZE = 10; +// Workflows rejects step outputs over 1MiB; keep the sitemap seed list well +// under that. The crawl visits at most maxPages URLs, so extra seeds are moot. +const SITEMAP_SEED_BYTE_BUDGET = 768 * 1024; + +function capSitemapSeeds(urls: string[], maxPages: number): string[] { + const seeds: string[] = []; + let bytes = 0; + for (const url of urls) { + if (seeds.length >= maxPages) break; + bytes += url.length + 3; // JSON quotes + comma + if (bytes > SITEMAP_SEED_BYTE_BUDGET) break; + seeds.push(url); + } + return seeds; +} + function countLighthouseBatchResults(results: LighthouseResult[]): { completed: number; failed: number; @@ -110,7 +126,7 @@ async function runDiscoveryPhase( pagesTotal: Math.min(result.urls.length + 1, maxPages), currentPhase: "crawling", }); - return { sitemapUrls: result.urls }; + return { sitemapUrls: capSitemapSeeds(result.urls, maxPages) }; }); } diff --git a/src/shared/rank-tracking.ts b/src/shared/rank-tracking.ts index 7fa12f1..0577fe2 100644 --- a/src/shared/rank-tracking.ts +++ b/src/shared/rank-tracking.ts @@ -36,6 +36,9 @@ export const SECONDS_PER_BATCH = 6; /** Maximum keywords allowed per rank tracking config */ export const MAX_KEYWORDS_PER_CONFIG = 1000; +/** Maximum length of a single tracked keyword */ +export const MAX_TRACKED_KEYWORD_LENGTH = 200; + /** Maximum configs (domain+location combos) per project */ export const MAX_CONFIGS_PER_PROJECT = 500; diff --git a/src/types/schemas/rank-tracking.ts b/src/types/schemas/rank-tracking.ts index e2eb3ee..34e350d 100644 --- a/src/types/schemas/rank-tracking.ts +++ b/src/types/schemas/rank-tracking.ts @@ -1,6 +1,7 @@ import type { InferSelectModel } from "drizzle-orm"; import { z } from "zod"; import { rankTrackingConfigs } from "@/db/schema"; +import { MAX_TRACKED_KEYWORD_LENGTH } from "@/shared/rank-tracking"; import { domainField } from "@/types/schemas/domain"; // --------------------------------------------------------------------------- @@ -101,7 +102,10 @@ export const estimateCostSchema = z.object({ export const addKeywordsSchema = z.object({ projectId: z.string().uuid(), configId: z.string().uuid(), - keywords: z.array(z.string().min(1).max(200)).min(1).max(2000), + keywords: z + .array(z.string().min(1).max(MAX_TRACKED_KEYWORD_LENGTH)) + .min(1) + .max(2000), }); export const removeKeywordsSchema = z.object({