PostHog reports every server error as affecting as many users as it has events (#534)
This commit is contained in:
parent
8752269149
commit
0311e72a96
@ -2,8 +2,32 @@ 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 { getAuth } from "@/lib/auth";
|
||||
import { AppError, asAppError, toClientError } from "@/server/lib/errors";
|
||||
import { captureServerError } from "@/server/lib/posthog";
|
||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||
|
||||
// This middleware wraps ensureUserMiddleware, so the resolved user context isn't
|
||||
// in scope when a downstream handler throws. Re-read the session instead — but
|
||||
// not via resolveHostedContext, which can create an organization as a side
|
||||
// effect. disableRefresh keeps this a pure read: it runs inside waitUntil, where
|
||||
// a refreshed Set-Cookie is discarded anyway. Any throw here would drop the whole
|
||||
// exception report, so every failure degrades to an anonymous capture.
|
||||
async function resolveErrorDistinctId(
|
||||
headers: Headers,
|
||||
): Promise<string | undefined> {
|
||||
if (!(await isHostedServerAuthMode())) return undefined;
|
||||
try {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers,
|
||||
query: { disableRefresh: true },
|
||||
});
|
||||
if (session?.user?.analyticsOptedOut === true) return undefined;
|
||||
return session?.user?.id;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// TanStack's serverFn validator throws a plain Error whose message is the
|
||||
// JSON-serialized standard-schema issue list. Treat those as input validation,
|
||||
@ -46,13 +70,16 @@ export const errorHandlingMiddleware = createMiddleware({
|
||||
const url = new URL(request.url);
|
||||
|
||||
console.error("server.function error:", error);
|
||||
const properties = {
|
||||
errorCode: appError?.code ?? "INTERNAL_ERROR",
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
...appError?.details,
|
||||
};
|
||||
waitUntil(
|
||||
captureServerError(error, {
|
||||
errorCode: appError?.code ?? "INTERNAL_ERROR",
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
...appError?.details,
|
||||
}),
|
||||
resolveErrorDistinctId(request.headers).then((distinctId) =>
|
||||
captureServerError(error, properties, distinctId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -62,10 +62,16 @@ export async function handleAutumnWebhookRequest(request: Request) {
|
||||
console.error("Autumn billing.updated sync failed", customerId, error, {
|
||||
cause: error instanceof Error ? error.cause : undefined,
|
||||
});
|
||||
await captureServerError(error, {
|
||||
source: "autumn_webhook",
|
||||
customer_id: customerId,
|
||||
});
|
||||
// Webhooks carry no user; the Autumn customer id is the organization id,
|
||||
// which at least makes "users affected" count organizations instead of events.
|
||||
await captureServerError(
|
||||
error,
|
||||
{
|
||||
source: "autumn_webhook",
|
||||
customer_id: customerId,
|
||||
},
|
||||
customerId,
|
||||
);
|
||||
return json({ error: "Webhook processing failed" }, 500);
|
||||
}
|
||||
|
||||
|
||||
@ -303,6 +303,8 @@ export async function handleGdprStorageErasure(
|
||||
// Raw fetch handlers run outside the server-function middleware, so
|
||||
// nothing else reports failures here.
|
||||
console.error("gdpr.storage-erasure failed:", error);
|
||||
// Deliberately anonymous: this request erases the user, so keying the
|
||||
// exception to their distinct id would recreate the person profile.
|
||||
await captureServerError(error, { source: "gdpr_storage_erasure" });
|
||||
return Response.json({ error: "Erasure failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
@ -16,9 +16,16 @@ function getServerPostHogClient(): PostHog | null {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `distinctId` must be the better-auth user id, the same identity
|
||||
* identifyAnalyticsUser and captureServerEvent send, so exceptions land on the
|
||||
* existing person profile. Omitting it makes posthog-node mint a fresh anonymous
|
||||
* id per event, so every error reports as many users affected as it has events.
|
||||
*/
|
||||
export async function captureServerError(
|
||||
error: unknown,
|
||||
properties: Record<string, string | null | undefined> = {},
|
||||
distinctId?: string,
|
||||
) {
|
||||
if (!(await isHostedServerAuthMode())) {
|
||||
return;
|
||||
@ -28,7 +35,7 @@ export async function captureServerError(
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await client.captureExceptionImmediate(error, undefined, {
|
||||
await client.captureExceptionImmediate(error, distinctId, {
|
||||
source: "server",
|
||||
...properties,
|
||||
});
|
||||
|
||||
@ -95,6 +95,7 @@ describe("instrumentMcpToolHandler", () => {
|
||||
await expect(wrapped({}, toolContext)).rejects.toThrow("upstream exploded");
|
||||
expect(mocks.captureServerError).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.captureServerError.mock.calls[0][0]).toBe(boom);
|
||||
expect(mocks.captureServerError.mock.calls[0][2]).toBe("user-1");
|
||||
});
|
||||
|
||||
it("rethrows expected errors without reporting them", async () => {
|
||||
|
||||
@ -106,6 +106,7 @@ export function instrumentMcpToolHandler<TArgs>(
|
||||
tool: toolName,
|
||||
issues: formatValidationIssues(validation.error),
|
||||
},
|
||||
context.auth.userId,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -183,10 +184,14 @@ export function instrumentMcpToolHandler<TArgs>(
|
||||
if (shouldCaptureAppErrorCode(appError?.code)) {
|
||||
console.error(`mcp.tool error (${toolName}):`, error);
|
||||
waitUntil(
|
||||
captureServerError(error, {
|
||||
errorCode: appError?.code ?? "INTERNAL_ERROR",
|
||||
tool: toolName,
|
||||
}),
|
||||
captureServerError(
|
||||
error,
|
||||
{
|
||||
errorCode: appError?.code ?? "INTERNAL_ERROR",
|
||||
tool: toolName,
|
||||
},
|
||||
context.auth.userId,
|
||||
),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
|
||||
@ -81,12 +81,16 @@ export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
|
||||
"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 captureServerError(
|
||||
error,
|
||||
{
|
||||
source: "site_audit_workflow",
|
||||
audit_id: auditId,
|
||||
organization_id: billingCustomer.organizationId,
|
||||
project_id: projectId,
|
||||
},
|
||||
billingCustomer.userId,
|
||||
);
|
||||
}
|
||||
const errorInfo = classifyAuditError(error);
|
||||
await pgStep(step, "mark-failed", DB_STEP, async () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user