Fix production errors: onboarding crash hardening + DataForSEO spend/noise cleanup (#282)
This commit is contained in:
parent
868924e1fe
commit
4e48f8344f
@ -9,6 +9,36 @@ let browserPostHogClientPromise: Promise<BrowserPostHogClient | null> | null =
|
||||
let browserPostHogInitialized = false;
|
||||
let analyticsCaptureEnabled = true;
|
||||
|
||||
type ExceptionEntry = {
|
||||
value?: unknown;
|
||||
mechanism?: { synthetic?: boolean };
|
||||
stacktrace?: { frames?: unknown[] };
|
||||
};
|
||||
|
||||
// Unactionable exceptions we don't want polluting error tracking. They share
|
||||
// the trait of not being our code: browser extensions inject promise rejections
|
||||
// and cross-origin scripts surface as a detail-less "Script error.", while the
|
||||
// global onerror handler synthesizes a stackless "undefined" when it fires
|
||||
// without a real Error object. Real app errors always carry a stack, so the
|
||||
// "undefined" rule is gated on synthetic + no frames to avoid false drops.
|
||||
function isIgnorableException(
|
||||
properties: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
const list = properties?.["$exception_list"];
|
||||
if (!Array.isArray(list) || list.length === 0) return false;
|
||||
return list.every((entry: ExceptionEntry) => {
|
||||
const value = typeof entry?.value === "string" ? entry.value : "";
|
||||
if (value.includes("Object Not Found Matching Id")) return true;
|
||||
if (value === "Script error.") return true;
|
||||
const frames = entry?.stacktrace?.frames;
|
||||
return (
|
||||
value === "undefined" &&
|
||||
entry?.mechanism?.synthetic === true &&
|
||||
(!Array.isArray(frames) || frames.length === 0)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getBrowserPostHogClient(): Promise<BrowserPostHogClient | null> {
|
||||
if (typeof window === "undefined" || !isHostedClientAuthMode()) {
|
||||
return Promise.resolve(null);
|
||||
@ -34,6 +64,15 @@ function getBrowserPostHogClient(): Promise<BrowserPostHogClient | null> {
|
||||
api_host: host,
|
||||
defaults: "2026-01-30",
|
||||
capture_exceptions: true,
|
||||
before_send(event) {
|
||||
if (
|
||||
event?.event === "$exception" &&
|
||||
isIgnorableException(event.properties)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
},
|
||||
capture_pageview: "history_change",
|
||||
respect_dnt: true,
|
||||
session_recording: {
|
||||
|
||||
@ -48,6 +48,15 @@ export const Route = createRootRoute({
|
||||
name: "viewport",
|
||||
content: "width=device-width, initial-scale=1, viewport-fit=cover",
|
||||
},
|
||||
// Disable browser auto-translate (Google Translate) app-wide. It rewrites
|
||||
// text nodes into <font> wrappers, which React then can't remove/insert,
|
||||
// crashing render with NotFoundError ("removeChild"/"insertBefore"). The
|
||||
// product UI is data-dense (keywords, domains, metrics) and not meaningful
|
||||
// to machine-translate; the marketing site is a separate app and unaffected.
|
||||
{
|
||||
name: "google",
|
||||
content: "notranslate",
|
||||
},
|
||||
{
|
||||
name: "apple-mobile-web-app-capable",
|
||||
content: "yes",
|
||||
@ -155,7 +164,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
import.meta.env.DEV && import.meta.env.VITE_SHOW_DEVTOOLS !== "false";
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning>
|
||||
<html suppressHydrationWarning translate="no">
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{ __html: themePreferenceInitScript }}
|
||||
|
||||
@ -173,14 +173,26 @@ async function remove(auditId: string, projectId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
const instance = await env.SITE_AUDIT_WORKFLOW.get(
|
||||
audit.workflowInstanceId,
|
||||
);
|
||||
try {
|
||||
const instance = await env.SITE_AUDIT_WORKFLOW.get(
|
||||
audit.workflowInstanceId,
|
||||
);
|
||||
await instance.terminate();
|
||||
} catch (error) {
|
||||
console.error(`Failed to terminate audit workflow ${audit.id}:`, error);
|
||||
throw new AppError("CONFLICT", "Unable to stop the running audit.");
|
||||
// terminate() throws when the instance already reached a terminal state
|
||||
// (it completed or errored in the moment before the user hit stop). That
|
||||
// race shouldn't block deletion — re-check the live status and only fail
|
||||
// if the workflow is genuinely still running.
|
||||
const status = await instance.status().catch(() => null);
|
||||
const stillRunning =
|
||||
status != null &&
|
||||
["queued", "running", "paused", "waiting", "waitingForPause"].includes(
|
||||
status.status,
|
||||
);
|
||||
if (stillRunning) {
|
||||
console.error(`Failed to terminate audit workflow ${audit.id}:`, error);
|
||||
throw new AppError("CONFLICT", "Unable to stop the running audit.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -29,7 +29,10 @@ export async function fetchBusinessListingsSearch(input: {
|
||||
limit: input.limit,
|
||||
}),
|
||||
]);
|
||||
const task = assertOk(response);
|
||||
// "No Search Results" (40501) is a valid empty result for obscure
|
||||
// businesses/keywords — DataForSEO still charges for it, so treat it as an
|
||||
// empty success instead of surfacing a charged-task error to the user.
|
||||
const task = assertOk(response, { treatNoResultsAsEmpty: true });
|
||||
return {
|
||||
data: task.result?.[0]?.items ?? [],
|
||||
billing: buildTaskBilling(task),
|
||||
@ -75,7 +78,10 @@ export async function fetchQuestionsAnswers(input: {
|
||||
depth: input.depth,
|
||||
}),
|
||||
]);
|
||||
const task = assertOk(response);
|
||||
// "No Search Results" (40501) is a valid empty result for obscure
|
||||
// businesses/keywords — DataForSEO still charges for it, so treat it as an
|
||||
// empty success instead of surfacing a charged-task error to the user.
|
||||
const task = assertOk(response, { treatNoResultsAsEmpty: true });
|
||||
return {
|
||||
data: combinedQuestionItems(task.result),
|
||||
billing: buildTaskBilling(task),
|
||||
|
||||
@ -60,6 +60,28 @@ describe("assertOk", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("appends the echoed request value to opaque 'Invalid Field' failures", () => {
|
||||
const task = {
|
||||
status_code: 40501,
|
||||
status_message: "Invalid Field: 'target'.",
|
||||
path: ["v3", "dataforseo_labs", "google", "domain_rank_overview", "live"],
|
||||
cost: 0.02,
|
||||
result_count: 0,
|
||||
data: { target: "not a valid domain", language_code: "en" },
|
||||
};
|
||||
try {
|
||||
assertOk({ status_code: 20000, tasks: [task] });
|
||||
throw new Error("expected assertOk to throw");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(DataforseoChargedTaskError);
|
||||
if (error instanceof DataforseoChargedTaskError) {
|
||||
expect(error.message).toBe(
|
||||
`Invalid Field: 'target'. (sent target="not a valid domain")`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the classifier for non-charged (no-cost) failures", () => {
|
||||
const classify = vi.fn(() => new AppError("BACKLINKS_NOT_ENABLED", "nope"));
|
||||
const task = {
|
||||
|
||||
@ -82,6 +82,26 @@ export function buildTaskBilling(
|
||||
return billing;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataForSEO echoes the posted request params back on `task.data`. Its
|
||||
* validation rejections are opaque ("Invalid Field: 'target'.") and name the
|
||||
* field but not the value we sent — and these tasks are charged, so we want to
|
||||
* know exactly what tripped them. Append the offending value so the charged
|
||||
* failure is diagnosable from the captured message alone.
|
||||
*/
|
||||
function describeInvalidField(
|
||||
message: string,
|
||||
task: DataforseoTaskLike,
|
||||
): string {
|
||||
const match = message.match(/Invalid Field:\s*'([^']+)'/i);
|
||||
if (!match) return message;
|
||||
const field = match[1];
|
||||
if (!isRecord(task.data)) return message;
|
||||
const value = task.data[field];
|
||||
if (value === undefined) return message;
|
||||
return `${message} (sent ${field}=${JSON.stringify(value)})`;
|
||||
}
|
||||
|
||||
/** DataForSEO's "No Search Results" (40501) — a successful empty result, not a failure. */
|
||||
export function isNoResultsTask(task: DataforseoTaskLike): boolean {
|
||||
return (
|
||||
@ -139,10 +159,11 @@ export function assertOk<T extends DataforseoTaskLike>(
|
||||
const classified = classify?.(task.status_code, message, path);
|
||||
if (classified) throw classified;
|
||||
|
||||
const detailedMessage = describeInvalidField(message, task);
|
||||
const billing = tryBuildTaskBilling(task);
|
||||
if (billing) throw new DataforseoChargedTaskError(message, billing);
|
||||
if (billing) throw new DataforseoChargedTaskError(detailedMessage, billing);
|
||||
|
||||
throw new AppError("INTERNAL_ERROR", message);
|
||||
throw new AppError("INTERNAL_ERROR", detailedMessage);
|
||||
}
|
||||
|
||||
return task;
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { getKeywordDataProvider } from "@/shared/keyword-locations";
|
||||
import {
|
||||
getKeywordDataProvider,
|
||||
isSupportedLanguageCode,
|
||||
} from "@/shared/keyword-locations";
|
||||
|
||||
export const DEFAULT_LOCATION_CODE = 2840;
|
||||
export const DEFAULT_LANGUAGE_CODE = "en";
|
||||
@ -35,5 +38,8 @@ export function assertLabsLocationCode(locationCode: number | undefined) {
|
||||
|
||||
export const languageCodeSchema = z
|
||||
.string()
|
||||
.min(2)
|
||||
.refine(isSupportedLanguageCode, {
|
||||
message:
|
||||
"Unsupported language code. Use a supported code such as 'en', 'es', 'de', or 'fr'.",
|
||||
})
|
||||
.describe("Language code (e.g. 'en', 'es', 'fr'). Defaults to 'en'.");
|
||||
|
||||
@ -14,5 +14,9 @@ describe("shouldCaptureAppErrorCode", () => {
|
||||
it("captures unexpected errors and unknown failures", () => {
|
||||
expect(shouldCaptureAppErrorCode("INTERNAL_ERROR")).toBe(true);
|
||||
expect(shouldCaptureAppErrorCode(undefined)).toBe(true);
|
||||
// On cloud the shared DataForSEO account has these add-ons, so these firing
|
||||
// signals a real platform problem — keep them reportable, don't suppress.
|
||||
expect(shouldCaptureAppErrorCode("BACKLINKS_NOT_ENABLED")).toBe(true);
|
||||
expect(shouldCaptureAppErrorCode("AI_SEARCH_NOT_ENABLED")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
getKeywordDataProvider,
|
||||
getLanguageCode,
|
||||
isLabsLocationCode,
|
||||
isSupportedLanguageCode,
|
||||
isSupportedLocationCode,
|
||||
} from "./keyword-locations";
|
||||
|
||||
@ -40,6 +41,21 @@ describe("keyword locations", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts every supported language code and rejects unknown ones", () => {
|
||||
// Every per-country default we send is, by construction, a supported code.
|
||||
for (const option of LOCATION_OPTIONS) {
|
||||
expect(isSupportedLanguageCode(option.languageCode)).toBe(true);
|
||||
}
|
||||
expect(isSupportedLanguageCode("en")).toBe(true);
|
||||
expect(isSupportedLanguageCode("zh-TW")).toBe(true);
|
||||
// Non-default codes from the master picker list are valid too (e.g. Hindi).
|
||||
expect(isSupportedLanguageCode("hi")).toBe(true);
|
||||
// Malformed/unsupported codes DataForSEO would reject as a charged failure.
|
||||
expect(isSupportedLanguageCode("english")).toBe(false);
|
||||
expect(isSupportedLanguageCode("en-US")).toBe(false);
|
||||
expect(isSupportedLanguageCode("zh-tw")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the picker sorted alphabetically with unique codes", () => {
|
||||
const labels = LOCATION_OPTIONS.map((option) => option.label);
|
||||
expect(labels).toEqual(labels.toSorted((a, b) => a.localeCompare(b)));
|
||||
|
||||
@ -658,10 +658,24 @@ const LOCATION_LANGUAGE: Record<number, string> = Object.fromEntries(
|
||||
LOCATION_OPTIONS.map((option) => [option.code, option.languageCode]),
|
||||
);
|
||||
|
||||
const SUPPORTED_LANGUAGE_CODES = new Set<string>(
|
||||
LANGUAGE_OPTIONS.map((language) => language.code),
|
||||
);
|
||||
|
||||
export function getLanguageCode(locationCode: number): string {
|
||||
return LOCATION_LANGUAGE[locationCode] ?? "en";
|
||||
}
|
||||
|
||||
/**
|
||||
* Language codes DataForSEO accepts — the master LANGUAGE_OPTIONS list. Callers
|
||||
* (e.g. MCP tools) can pass an arbitrary `language_code`; an unsupported one is
|
||||
* otherwise rejected by DataForSEO as an opaque *charged* "Invalid Field:
|
||||
* 'language_code'." failure, so we validate against this set first (cost 0).
|
||||
*/
|
||||
export function isSupportedLanguageCode(languageCode: string): boolean {
|
||||
return SUPPORTED_LANGUAGE_CODES.has(languageCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Countries where DataForSEO offers more than one language, from the Labs
|
||||
* locations_and_languages endpoint (each country's default is included).
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user