Triage active PostHog errors: validator noise, workflow output cap, Autumn retries, deploy-reset noise (#361)

This commit is contained in:
Ben Senescu 2026-07-05 23:22:44 -04:00 committed by GitHub
parent 828fb17073
commit 3c7e704b4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 131 additions and 28 deletions

View File

@ -2,6 +2,7 @@ import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { addTrackingKeywords } from "@/serverFunctions/rank-tracking"; import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
import { MAX_TRACKED_KEYWORD_LENGTH } from "@/shared/rank-tracking";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
@ -46,6 +47,12 @@ export function AddKeywordsPanel({
.split("\n") .split("\n")
.map((l) => l.trim()) .map((l) => l.trim())
.filter(Boolean); .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); if (lines.length > 0) mutation.mutate(lines);
}} }}
disabled={isPending || !keywordInput.trim()} disabled={isPending || !keywordInput.trim()}

View File

@ -2,9 +2,29 @@ import { createMiddleware } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server"; import { getRequest } from "@tanstack/react-start/server";
import { waitUntil } from "cloudflare:workers"; import { waitUntil } from "cloudflare:workers";
import { shouldCaptureAppErrorCode } from "@/shared/error-codes"; 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"; 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({ export const errorHandlingMiddleware = createMiddleware({
type: "function", type: "function",
}).server(async (c) => { }).server(async (c) => {
@ -17,7 +37,9 @@ export const errorHandlingMiddleware = createMiddleware({
throw new Error("INTERNAL_ERROR", { cause: error }); 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)) { if (shouldCaptureAppErrorCode(appError?.code)) {
const request = getRequest(); const request = getRequest();
@ -34,6 +56,6 @@ export const errorHandlingMiddleware = createMiddleware({
); );
} }
throw toClientError(error); throw toClientError(appError ?? error);
} }
}); });

View File

@ -3,4 +3,34 @@ import { getRequiredEnvValue } from "@/server/lib/runtime-env";
export const autumn = new Autumn({ export const autumn = new Autumn({
secretKey: () => getRequiredEnvValue("AUTUMN_SECRET_KEY"), 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<Autumn["track"]>[1] = {
retryCodes: ["429"],
retries: {
strategy: "backoff",
backoff: {
initialInterval: 250,
maxInterval: 2000,
exponent: 1.5,
maxElapsedTime: 8000,
},
retryConnectionErrors: false,
},
};

View File

@ -9,7 +9,7 @@ import {
roundUsdForBilling, roundUsdForBilling,
} from "@/shared/billing"; } from "@/shared/billing";
import type { CreditFeature } from "@/shared/billing-credit-features"; 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 { captureServerEvent } from "@/server/lib/posthog";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
@ -127,7 +127,8 @@ export async function trackUsageCreditSpend(args: {
}; };
if (monthlyDeduct > 0) { if (monthlyDeduct > 0) {
await autumn.track({ await autumn.track(
{
customerId: args.customerId, customerId: args.customerId,
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
value: monthlyDeduct, value: monthlyDeduct,
@ -135,11 +136,14 @@ export async function trackUsageCreditSpend(args: {
...properties, ...properties,
balanceFeatureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, balanceFeatureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
}, },
}); },
AUTUMN_TRACK_RETRY_OPTIONS,
);
} }
if (topupDeduct > 0) { if (topupDeduct > 0) {
await autumn.track({ await autumn.track(
{
customerId: args.customerId, customerId: args.customerId,
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
value: topupDeduct, value: topupDeduct,
@ -147,7 +151,9 @@ export async function trackUsageCreditSpend(args: {
...properties, ...properties,
balanceFeatureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, balanceFeatureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
}, },
}); },
AUTUMN_TRACK_RETRY_OPTIONS,
);
} }
await captureServerEvent({ await captureServerEvent({

View File

@ -31,6 +31,7 @@ vi.mock("@/server/billing/autumn", () => ({
check: checkMock, check: checkMock,
track: trackMock, track: trackMock,
}, },
AUTUMN_TRACK_RETRY_OPTIONS: {},
})); }));
// Keep the real subscription module (its assertUsageCreditsAvailable calls the // 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, featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
value: EXPECTED_CREDITS, value: EXPECTED_CREDITS,
}), }),
expect.anything(),
); );
}); });
@ -207,6 +209,7 @@ describe("meterDataforseoCall with split balances", () => {
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
value: EXPECTED_CREDITS, value: EXPECTED_CREDITS,
}), }),
expect.anything(),
); );
}); });
@ -226,6 +229,7 @@ describe("meterDataforseoCall with split balances", () => {
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
value: monthlyAvailable, value: monthlyAvailable,
}), }),
expect.anything(),
); );
expect(trackMock).toHaveBeenCalledWith( expect(trackMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -233,6 +237,7 @@ describe("meterDataforseoCall with split balances", () => {
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
value: EXPECTED_CREDITS - monthlyAvailable, value: EXPECTED_CREDITS - monthlyAvailable,
}), }),
expect.anything(),
); );
}); });
@ -271,6 +276,7 @@ describe("meterDataforseoCall with split balances", () => {
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
value: EXPECTED_CREDITS, value: EXPECTED_CREDITS,
}), }),
expect.anything(),
); );
}); });
@ -316,6 +322,7 @@ describe("meterDataforseoCall with split balances", () => {
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
value: EXPECTED_CREDITS, value: EXPECTED_CREDITS,
}), }),
expect.anything(),
); );
}); });

View File

@ -67,12 +67,20 @@ export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
// Workflow entrypoints run outside the server-function middleware, so // Workflow entrypoints run outside the server-function middleware, so
// nothing else forwards this throw to PostHog as a $exception. Capture it // nothing else forwards this throw to PostHog as a $exception. Capture it
// here (awaited — Workflows have no ctx.waitUntil) before re-throwing. // here (awaited — Workflows have no ctx.waitUntil) before re-throwing.
// 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, { await captureServerError(error, {
source: "site_audit_workflow", source: "site_audit_workflow",
audit_id: auditId, audit_id: auditId,
organization_id: billingCustomer.organizationId, organization_id: billingCustomer.organizationId,
project_id: projectId, project_id: projectId,
}); });
}
await pgStep(step, "mark-failed", undefined, async () => { await pgStep(step, "mark-failed", undefined, async () => {
await AuditRepository.failAudit(auditId, event.instanceId); await AuditRepository.failAudit(auditId, event.instanceId);

View File

@ -19,6 +19,22 @@ import { pgStep } from "@/server/workflows/pgStep";
const LIGHTHOUSE_URL_BATCH_SIZE = 10; 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[]): { function countLighthouseBatchResults(results: LighthouseResult[]): {
completed: number; completed: number;
failed: number; failed: number;
@ -110,7 +126,7 @@ async function runDiscoveryPhase(
pagesTotal: Math.min(result.urls.length + 1, maxPages), pagesTotal: Math.min(result.urls.length + 1, maxPages),
currentPhase: "crawling", currentPhase: "crawling",
}); });
return { sitemapUrls: result.urls }; return { sitemapUrls: capSitemapSeeds(result.urls, maxPages) };
}); });
} }

View File

@ -36,6 +36,9 @@ export const SECONDS_PER_BATCH = 6;
/** Maximum keywords allowed per rank tracking config */ /** Maximum keywords allowed per rank tracking config */
export const MAX_KEYWORDS_PER_CONFIG = 1000; 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 */ /** Maximum configs (domain+location combos) per project */
export const MAX_CONFIGS_PER_PROJECT = 500; export const MAX_CONFIGS_PER_PROJECT = 500;

View File

@ -1,6 +1,7 @@
import type { InferSelectModel } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { rankTrackingConfigs } from "@/db/schema"; import { rankTrackingConfigs } from "@/db/schema";
import { MAX_TRACKED_KEYWORD_LENGTH } from "@/shared/rank-tracking";
import { domainField } from "@/types/schemas/domain"; import { domainField } from "@/types/schemas/domain";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -101,7 +102,10 @@ export const estimateCostSchema = z.object({
export const addKeywordsSchema = z.object({ export const addKeywordsSchema = z.object({
projectId: z.string().uuid(), projectId: z.string().uuid(),
configId: 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({ export const removeKeywordsSchema = z.object({