Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/web/lib/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
2 changes: 2 additions & 0 deletions apps/web/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
25 changes: 15 additions & 10 deletions packages/db/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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/);
});
22 changes: 11 additions & 11 deletions packages/db/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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;
}
Expand Down
Loading