Triage active PostHog errors: validator noise, workflow output cap, Autumn retries, deploy-reset noise (#361)
This commit is contained in:
parent
828fb17073
commit
3c7e704b4a
@ -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()}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@ -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<Autumn["track"]>[1] = {
|
||||
retryCodes: ["429"],
|
||||
retries: {
|
||||
strategy: "backoff",
|
||||
backoff: {
|
||||
initialInterval: 250,
|
||||
maxInterval: 2000,
|
||||
exponent: 1.5,
|
||||
maxElapsedTime: 8000,
|
||||
},
|
||||
retryConnectionErrors: false,
|
||||
},
|
||||
};
|
||||
|
||||
@ -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,7 +127,8 @@ export async function trackUsageCreditSpend(args: {
|
||||
};
|
||||
|
||||
if (monthlyDeduct > 0) {
|
||||
await autumn.track({
|
||||
await autumn.track(
|
||||
{
|
||||
customerId: args.customerId,
|
||||
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
||||
value: monthlyDeduct,
|
||||
@ -135,11 +136,14 @@ export async function trackUsageCreditSpend(args: {
|
||||
...properties,
|
||||
balanceFeatureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
|
||||
},
|
||||
});
|
||||
},
|
||||
AUTUMN_TRACK_RETRY_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
if (topupDeduct > 0) {
|
||||
await autumn.track({
|
||||
await autumn.track(
|
||||
{
|
||||
customerId: args.customerId,
|
||||
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||
value: topupDeduct,
|
||||
@ -147,7 +151,9 @@ export async function trackUsageCreditSpend(args: {
|
||||
...properties,
|
||||
balanceFeatureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||
},
|
||||
});
|
||||
},
|
||||
AUTUMN_TRACK_RETRY_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
await captureServerEvent({
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -67,12 +67,20 @@ export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
|
||||
// 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.
|
||||
// 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);
|
||||
|
||||
|
||||
@ -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) };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user