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 { 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()}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user