diff --git a/apps/web/lib/analytics.test.ts b/apps/web/lib/analytics.test.ts index 1bff2ff..b47b5be 100644 --- a/apps/web/lib/analytics.test.ts +++ b/apps/web/lib/analytics.test.ts @@ -22,3 +22,8 @@ test("what anyone plucked never leaves: titles, queries and hashes are dropped, expect(out.properties.$current_url).toBe("https://getpluck.io/search"); expect(out.properties.$referrer).toBe("https://getpluck.io/?utm_source=newsletter"); }); + +test("an error from another origin's script, with no message or stack, is not sent", () => { + expect(scrub(event("$exception", { $exception_list: [{ type: "Error", value: "Script error." }] }), null)).toBeNull(); + expect(scrub(event("$exception", { $exception_list: [{ type: "TypeError", value: "x is undefined" }] }), null)?.event).toBe("$exception"); +}); diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index 3b1d55f..adde7f5 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -94,6 +94,8 @@ export function scrub(event: CaptureResult | null, given: Consent | null): Captu if (!event) return null; if (given !== "yes" && event.event !== "$exception") return null; const p = event.properties; + // A script from another origin (an in-app browser's, an extension's) fails with no message and no stack: nothing to fix. + if (event.event === "$exception" && /^Script error\.?$/.test(p.$exception_list?.[0]?.value ?? "")) return null; delete p.title; // item pages are titled with the item delete p.$title; for (const k of ["$current_url", "$referrer", "$initial_current_url", "$initial_referrer"]) if (typeof p[k] === "string") p[k] = keepUtm(p[k]); diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts index d6534fe..bc201aa 100644 --- a/packages/db/src/client.test.ts +++ b/packages/db/src/client.test.ts @@ -1,22 +1,21 @@ import { createServer, connect as tcp, type AddressInfo, type Server } from "node:net"; import { MongoClient } from "mongodb"; import { expect, test } from "vitest"; -import { keepConnected } from "./client.ts"; +import { reconnectWhenClosed } from "./client.ts"; -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const db = new URL(process.env.MONGODB_URI!.replace(/^mongodb:/, "http:")); // the test database, host and port -test("a client whose first connect failed connects once the database is back", async () => { - // A free port with nothing on it yet: connecting there fails like an Atlas election with no primary. +test("a client whose connect failed connects again on the next query once the database is back", async () => { + // A free port with nothing on it yet: connecting there fails like an Atlas election with no primary, or a + // serverless instance frozen mid-connect. const free = createServer().listen(0, "127.0.0.1"); await new Promise((r) => free.once("listening", r)); const port = (free.address() as AddressInfo).port; await new Promise((r) => free.close(r)); - const c = new MongoClient(`mongodb://127.0.0.1:${port}/x?directConnection=true&serverSelectionTimeoutMS=300`); - const connected = keepConnected(c, { firstDelayMs: 100, maxDelayMs: 200 }); - await sleep(700); - await expect(c.db().command({ ping: 1 })).rejects.toThrow(); // the closed topology the driver keeps + const c = reconnectWhenClosed(new MongoClient(`mongodb://127.0.0.1:${port}/x?directConnection=true&serverSelectionTimeoutMS=300`)); + await expect(c.connect()).rejects.toThrow(); + await expect(c.db().command({ ping: 1 })).rejects.toThrow(); // still down: this query's own connect fails too // The database comes back on that port (a proxy to the test one). const proxy: Server = createServer((sock) => { @@ -26,10 +25,16 @@ test("a client whose first connect failed connects once the database is back", a sock.on("error", () => up.destroy()); }).listen(port, "127.0.0.1"); try { - await connected; - expect((await c.db().command({ ping: 1 })).ok).toBe(1); + expect((await c.db().command({ ping: 1 })).ok).toBe(1); // no wait, no timer: the query reconnects } finally { await c.close(); await new Promise((r) => proxy.close(r)); } }); + +test("a closed client stays closed", async () => { + const c = reconnectWhenClosed(new MongoClient(process.env.MONGODB_URI!)); + await c.connect(); + await c.close(); + await expect(c.db().command({ ping: 1 })).rejects.toThrow(/must be connected/); +}); diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 4f679ba..fba2e93 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -2,24 +2,24 @@ import { MongoClient, type Db } from "mongodb"; let client: MongoClient | undefined; -// The driver (mongodb 7) keeps the topology of a first connect that failed, closed, and its auto-connect only -// connects when there is no topology: every later query answers "Topology is closed" until the process dies. A -// brief Atlas election at the wrong moment broke warm instances that way. So connect at once, and if that fails, -// connect again (connect() replaces a topology that is not connected) with backoff until it holds. -export function keepConnected(c: MongoClient, { firstDelayMs = 1000, maxDelayMs = 30_000 } = {}): Promise { - return new Promise((resolve) => { - const attempt = (delay: number) => - c.connect().then(() => resolve(), () => setTimeout(() => attempt(Math.min(delay * 2, maxDelayMs)), delay).unref()); - attempt(firstDelayMs); +// The driver (mongodb 7) keeps the topology of a connect that failed, closed, and its auto-connect only connects when +// there is no topology: every later query answers "Topology is closed" until the process dies. On Vercel an instance +// frozen mid-connect thaws to exactly that, and a retry on a timer is frozen with it. So drop a closed topology: the +// next query connects again, inside the request that needs it. close() drops its own first, so this leaves it alone. +export function reconnectWhenClosed(c: MongoClient): MongoClient { + const inner = c as unknown as { topology?: { s: { id: number } } }; // why: `topology` is the driver's, not in its types + c.on("topologyClosed", (e) => { + if (inner.topology?.s.id === e.topologyId) inner.topology = undefined; }); + return c; } // Pool capped at 10 per instance (shared Atlas cluster guardrail). export function getClient(uri = process.env.MONGODB_URI): MongoClient { if (!uri) throw new Error("MONGODB_URI is not set"); if (!client) { - client = new MongoClient(uri, { maxPoolSize: 10 }); - void keepConnected(client); + client = reconnectWhenClosed(new MongoClient(uri, { maxPoolSize: 10 })); + client.connect().catch(() => {}); // warm up; a failure is retried by the first query } return client; }