@app-vitals/agent-lib 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { registerGracefulShutdown } from "./graceful-shutdown.ts";
3
+
4
+ /** Records signal listeners without touching the real `process` object. */
5
+ function fakeSignalSource() {
6
+ const listeners: Record<string, () => void> = {};
7
+ return {
8
+ on(event: "SIGTERM" | "SIGINT", listener: () => void) {
9
+ listeners[event] = listener;
10
+ },
11
+ listeners,
12
+ };
13
+ }
14
+
15
+ /** Fake server.stop() — records the `closeActiveConnections` arg per call and resolves immediately. */
16
+ function fakeServer(calls: string[]) {
17
+ return {
18
+ stop(closeActiveConnections?: boolean) {
19
+ calls.push(closeActiveConnections ? "stop(true)" : "stop()");
20
+ return Promise.resolve();
21
+ },
22
+ };
23
+ }
24
+
25
+ /** Fake setTimeout — never fires on its own; the test triggers it manually via `fire()`. */
26
+ function fakeTimeout() {
27
+ let scheduled: { fn: () => void; ms: number } | undefined;
28
+ return {
29
+ setTimeoutFn: (fn: () => void, ms: number) => {
30
+ scheduled = { fn, ms };
31
+ return "handle";
32
+ },
33
+ clearTimeoutFn: () => {
34
+ scheduled = undefined;
35
+ },
36
+ fire: () => scheduled?.fn(),
37
+ ms: () => scheduled?.ms,
38
+ };
39
+ }
40
+
41
+ /** Fake process.exit() that also resolves a promise so tests can await shutdown completion. */
42
+ function fakeExit() {
43
+ const calls: number[] = [];
44
+ let resolve!: () => void;
45
+ const settled = new Promise<void>((r) => {
46
+ resolve = r;
47
+ });
48
+ return {
49
+ exit: (code: number) => {
50
+ calls.push(code);
51
+ resolve();
52
+ },
53
+ calls,
54
+ settled,
55
+ };
56
+ }
57
+
58
+ const NOOP_LOGGER = { log: () => {}, error: () => {} };
59
+
60
+ /**
61
+ * Builds a fresh set of fakes and registers a shutdown handler against them.
62
+ * Every test shares this same wiring — only `cleanup` and `hardDeadlineMs`
63
+ * vary — so the fixture construction lives here once instead of being
64
+ * repeated per test.
65
+ */
66
+ function harness(
67
+ overrides: {
68
+ cleanup?: Array<() => Promise<void>>;
69
+ hardDeadlineMs?: number;
70
+ } = {},
71
+ ) {
72
+ const calls: string[] = [];
73
+ const server = fakeServer(calls);
74
+ const signal = fakeSignalSource();
75
+ const timeout = fakeTimeout();
76
+ const exit = fakeExit();
77
+
78
+ const handle = registerGracefulShutdown({
79
+ server,
80
+ cleanup: overrides.cleanup ?? [async () => {}],
81
+ ...(overrides.hardDeadlineMs !== undefined
82
+ ? { hardDeadlineMs: overrides.hardDeadlineMs }
83
+ : {}),
84
+ process: signal,
85
+ setTimeoutFn: timeout.setTimeoutFn,
86
+ clearTimeoutFn: timeout.clearTimeoutFn,
87
+ exit: exit.exit,
88
+ logger: NOOP_LOGGER,
89
+ });
90
+
91
+ return { calls, signal, timeout, exit, handle };
92
+ }
93
+
94
+ describe("registerGracefulShutdown", () => {
95
+ test("calls server.stop() before running cleanup fns", async () => {
96
+ const { calls, signal, exit } = harness({
97
+ cleanup: [
98
+ async () => {
99
+ calls.push("cleanup:0");
100
+ },
101
+ ],
102
+ });
103
+
104
+ signal.listeners.SIGTERM();
105
+ await exit.settled;
106
+
107
+ expect(calls).toEqual(["stop()", "cleanup:0"]);
108
+ });
109
+
110
+ test("runs cleanup fns in the given order", async () => {
111
+ const { calls, signal, exit } = harness({
112
+ cleanup: [
113
+ async () => {
114
+ calls.push("cleanup:a");
115
+ },
116
+ async () => {
117
+ calls.push("cleanup:b");
118
+ },
119
+ async () => {
120
+ calls.push("cleanup:c");
121
+ },
122
+ ],
123
+ });
124
+
125
+ signal.listeners.SIGTERM();
126
+ await exit.settled;
127
+
128
+ expect(calls).toEqual(["stop()", "cleanup:a", "cleanup:b", "cleanup:c"]);
129
+ });
130
+
131
+ test("exits with code 0 on clean successful shutdown", async () => {
132
+ const { signal, exit } = harness();
133
+
134
+ signal.listeners.SIGTERM();
135
+ await exit.settled;
136
+
137
+ expect(exit.calls).toEqual([0]);
138
+ });
139
+
140
+ test("hard deadline forces server.stop(true) and exit(1) when cleanup never resolves", async () => {
141
+ const { calls, signal, timeout, exit } = harness({
142
+ cleanup: [
143
+ () =>
144
+ new Promise<void>(() => {
145
+ // never resolves — simulates a stuck cleanup fn
146
+ }),
147
+ ],
148
+ hardDeadlineMs: 20_000,
149
+ });
150
+
151
+ signal.listeners.SIGTERM();
152
+
153
+ // Let the graceful stop() + cleanup kick-off settle onto the stuck promise
154
+ // before manually firing the deadline (no real waiting involved).
155
+ await Promise.resolve();
156
+ await Promise.resolve();
157
+
158
+ expect(timeout.ms()).toBe(20_000);
159
+ timeout.fire();
160
+ await exit.settled;
161
+
162
+ expect(calls).toContain("stop(true)");
163
+ expect(exit.calls).toEqual([1]);
164
+ });
165
+
166
+ test("a second SIGTERM after the first is a no-op", async () => {
167
+ const { calls, signal, exit } = harness({
168
+ cleanup: [
169
+ async () => {
170
+ calls.push("cleanup:0");
171
+ },
172
+ ],
173
+ });
174
+
175
+ signal.listeners.SIGTERM();
176
+ await exit.settled;
177
+
178
+ signal.listeners.SIGTERM();
179
+ // Flush microtasks so a (bugged) second invocation would have had a chance to run.
180
+ await Promise.resolve();
181
+ await Promise.resolve();
182
+
183
+ expect(calls).toEqual(["stop()", "cleanup:0"]);
184
+ expect(exit.calls).toEqual([0]);
185
+ });
186
+
187
+ test("SIGINT triggers the same shutdown sequence as SIGTERM", async () => {
188
+ const { calls, signal, exit } = harness({
189
+ cleanup: [
190
+ async () => {
191
+ calls.push("cleanup:0");
192
+ },
193
+ ],
194
+ });
195
+
196
+ signal.listeners.SIGINT();
197
+ await exit.settled;
198
+
199
+ expect(calls).toEqual(["stop()", "cleanup:0"]);
200
+ expect(exit.calls).toEqual([0]);
201
+ });
202
+
203
+ test("exposes a readable shuttingDown flag, set synchronously on signal", async () => {
204
+ const { signal, exit, handle } = harness();
205
+
206
+ expect(handle.isShuttingDown()).toBe(false);
207
+
208
+ signal.listeners.SIGTERM();
209
+
210
+ // True immediately, before the async stop/cleanup chain has settled.
211
+ expect(handle.isShuttingDown()).toBe(true);
212
+
213
+ await exit.settled;
214
+ expect(handle.isShuttingDown()).toBe(true);
215
+ });
216
+
217
+ test("a cleanup fn that throws does not prevent later cleanup fns or process.exit", async () => {
218
+ const { calls, signal, exit } = harness({
219
+ cleanup: [
220
+ async () => {
221
+ calls.push("cleanup:a");
222
+ throw new Error("boom");
223
+ },
224
+ async () => {
225
+ calls.push("cleanup:b");
226
+ },
227
+ ],
228
+ });
229
+
230
+ signal.listeners.SIGTERM();
231
+ await exit.settled;
232
+
233
+ expect(calls).toEqual(["stop()", "cleanup:a", "cleanup:b"]);
234
+ expect(exit.calls).toEqual([0]);
235
+ });
236
+ });
package/org-repo.ts ADDED
@@ -0,0 +1,9 @@
1
+ /** Returns true when `s` is a valid "org/repo" string with non-empty org and repo parts. */
2
+ export function isOrgRepo(s: string): boolean {
3
+ const parts = s.split("/");
4
+ return (
5
+ parts.length === 2 &&
6
+ parts[0].trim().length > 0 &&
7
+ parts[1].trim().length > 0
8
+ );
9
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isOrgRepo } from "./org-repo.ts";
3
+
4
+ describe("isOrgRepo — valid strings", () => {
5
+ test("returns true for a simple org/repo string", () => {
6
+ expect(isOrgRepo("app-vitals/shipwright")).toBe(true);
7
+ });
8
+
9
+ test("returns true when org and repo contain hyphens and numbers", () => {
10
+ expect(isOrgRepo("my-org-1/my-repo-2")).toBe(true);
11
+ });
12
+ });
13
+
14
+ describe("isOrgRepo — invalid strings", () => {
15
+ test("returns false for a string with no slash", () => {
16
+ expect(isOrgRepo("shipwright")).toBe(false);
17
+ });
18
+
19
+ test("returns false for a string with more than one slash", () => {
20
+ expect(isOrgRepo("app-vitals/shipwright/extra")).toBe(false);
21
+ });
22
+
23
+ test("returns false when the org part is empty", () => {
24
+ expect(isOrgRepo("/shipwright")).toBe(false);
25
+ });
26
+
27
+ test("returns false when the repo part is empty", () => {
28
+ expect(isOrgRepo("app-vitals/")).toBe(false);
29
+ });
30
+
31
+ test("returns false when the org part is only whitespace", () => {
32
+ expect(isOrgRepo(" /shipwright")).toBe(false);
33
+ });
34
+
35
+ test("returns false when the repo part is only whitespace", () => {
36
+ expect(isOrgRepo("app-vitals/ ")).toBe(false);
37
+ });
38
+
39
+ test("returns false for an empty string", () => {
40
+ expect(isOrgRepo("")).toBe(false);
41
+ });
42
+ });
package/package.json CHANGED
@@ -1,8 +1,41 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-lib",
3
- "version": "0.0.1",
4
- "description": "Placeholder reserving the package name. The real release is published from github.com/app-vitals/agent.",
3
+ "version": "0.1.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/app-vitals/agent.git",
7
+ "directory": "lib"
8
+ },
5
9
  "license": "MIT",
6
- "repository": {"type": "git", "url": "git+https://github.com/app-vitals/agent.git"},
7
- "publishConfig": {"access": "public"}
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "type": "module",
14
+ "exports": {
15
+ "./admin-types": "./admin-types.ts",
16
+ "./agent-default-tools": "./agent-default-tools.ts",
17
+ "./chat-notify": "./chat-notify.ts",
18
+ "./claim-ttl": "./claim-ttl.ts",
19
+ "./claude-timeout": "./claude-timeout.ts",
20
+ "./cli-flags": "./cli-flags.ts",
21
+ "./clone-plan": "./clone-plan.ts",
22
+ "./github-login": "./github-login.ts",
23
+ "./graceful-shutdown": "./graceful-shutdown.ts",
24
+ "./http": "./http.ts",
25
+ "./org-repo": "./org-repo.ts",
26
+ "./pg-sslmode": "./pg-sslmode.ts",
27
+ "./pool-observability": "./pool-observability.ts",
28
+ "./pricing": "./pricing.ts",
29
+ "./progress-phases": "./progress-phases.ts",
30
+ "./request-context": "./request-context.ts",
31
+ "./secret-env-vars": "./secret-env-vars.ts",
32
+ "./default-account": "./default-account.ts",
33
+ "./default-agent-env": "./default-agent-env.ts",
34
+ "./sentry": "./sentry.ts",
35
+ "./task-store-types": "./task-store-types.ts",
36
+ "./web/*": "./web/*"
37
+ },
38
+ "dependencies": {
39
+ "@sentry/bun": "^10.63.0"
40
+ }
8
41
  }
package/pg-sslmode.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * lib/pg-sslmode.ts
3
+ *
4
+ * Pins node-postgres's TLS behavior for a Postgres connection string.
5
+ *
6
+ * `pg` 8.x (via pg-connection-string 2.x) treats `sslmode=prefer`, `require`,
7
+ * and `verify-ca` as aliases for `verify-full` — the server certificate chain
8
+ * and hostname are both verified — and logs a SECURITY WARNING saying the next
9
+ * major (pg 9 / pg-connection-string 3) will switch them to libpq semantics,
10
+ * where `require` means "encrypt, verify nothing". Upgrading `pg` would then
11
+ * silently drop certificate verification for every deployment whose URL says
12
+ * `sslmode=require`.
13
+ *
14
+ * Rewriting those modes to `verify-full` in the string handed to `pg` keeps
15
+ * today's (verifying) behavior across that upgrade and silences the warning.
16
+ * It is applied only to the `pg.Pool` connection string — never to the env var
17
+ * itself — because the same URL is also read by the Prisma CLI
18
+ * (`prisma migrate deploy` via prisma.config.ts), whose engine recognises only
19
+ * `disable|prefer|require` and would read `verify-full` as `prefer`.
20
+ *
21
+ * Left untouched:
22
+ * - URLs with no `sslmode`, or `disable` / `verify-full` / `no-verify`
23
+ * (none of them warn, and none change meaning in pg 9);
24
+ * - URLs that set `uselibpqcompat=true`: that is an explicit opt-in to libpq
25
+ * semantics, which this must not override;
26
+ * - strings that are not parseable as a URL (pg accepts other forms; pass
27
+ * them through for pg to handle or reject).
28
+ */
29
+
30
+ /** The sslmode values pg 8 aliases to `verify-full` (and warns about). */
31
+ const LEGACY_VERIFY_FULL_ALIASES = new Set(["prefer", "require", "verify-ca"]);
32
+
33
+ /**
34
+ * Returns `connectionString` with a legacy `sslmode` alias replaced by
35
+ * `verify-full`, or the input unchanged when no rewrite applies.
36
+ */
37
+ export function pinPgSslMode(connectionString: string): string {
38
+ let url: URL;
39
+ try {
40
+ url = new URL(connectionString);
41
+ } catch {
42
+ return connectionString;
43
+ }
44
+
45
+ // Resolve the effective `sslmode` the same way pg-connection-string's
46
+ // parse() does: iterate every query param in document order and let later
47
+ // entries overwrite earlier ones (`config[entry[0]] = entry[1]`). A
48
+ // duplicate `sslmode` therefore resolves to its LAST occurrence — not the
49
+ // first, which is what `URLSearchParams#get()` would return — so this must
50
+ // not use `.get()` here.
51
+ let sslmode: string | null = null;
52
+ for (const [key, value] of url.searchParams) {
53
+ if (key === "sslmode") {
54
+ sslmode = value;
55
+ }
56
+ }
57
+ if (sslmode === null || !LEGACY_VERIFY_FULL_ALIASES.has(sslmode)) {
58
+ return connectionString;
59
+ }
60
+ if (url.searchParams.get("uselibpqcompat") === "true") {
61
+ return connectionString;
62
+ }
63
+
64
+ url.searchParams.set("sslmode", "verify-full");
65
+ return url.toString();
66
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { pinPgSslMode } from "./pg-sslmode.ts";
3
+
4
+ const BASE = "postgresql://user:pass@db.example.test:5432/app";
5
+
6
+ describe("pinPgSslMode — legacy aliases are pinned to verify-full", () => {
7
+ for (const mode of ["prefer", "require", "verify-ca"]) {
8
+ test(`rewrites sslmode=${mode} to sslmode=verify-full`, () => {
9
+ const pinned = new URL(pinPgSslMode(`${BASE}?sslmode=${mode}`));
10
+ expect(pinned.searchParams.get("sslmode")).toBe("verify-full");
11
+ });
12
+ }
13
+
14
+ test("preserves credentials, host, database, and other params", () => {
15
+ const pinned = new URL(
16
+ pinPgSslMode(`${BASE}?connection_limit=5&sslmode=require&schema=public`),
17
+ );
18
+ expect(pinned.username).toBe("user");
19
+ expect(pinned.password).toBe("pass");
20
+ expect(pinned.host).toBe("db.example.test:5432");
21
+ expect(pinned.pathname).toBe("/app");
22
+ expect(pinned.searchParams.get("connection_limit")).toBe("5");
23
+ expect(pinned.searchParams.get("schema")).toBe("public");
24
+ });
25
+
26
+ test("keeps a URL-encoded password byte-for-byte when rewriting", () => {
27
+ const pinned = pinPgSslMode(
28
+ "postgresql://user:p%40ss%2Fw@localhost:5432/app?sslmode=require",
29
+ );
30
+ expect(pinned).toBe(
31
+ "postgresql://user:p%40ss%2Fw@localhost:5432/app?sslmode=verify-full",
32
+ );
33
+ });
34
+
35
+ test("a duplicate sslmode resolves to the LAST occurrence, like pg-connection-string", () => {
36
+ // pg-connection-string's parse() iterates every query param and lets
37
+ // later entries overwrite earlier ones, so `pg` itself connects using
38
+ // the second `sslmode` here ("require"), not the first ("verify-full").
39
+ // Using URLSearchParams#get() (first-match) would wrongly see
40
+ // "verify-full" and skip the rewrite, leaving the string's final
41
+ // sslmode as the unrewritten legacy "require".
42
+ const pinned = new URL(
43
+ pinPgSslMode(`${BASE}?sslmode=verify-full&sslmode=require`),
44
+ );
45
+ expect(pinned.searchParams.getAll("sslmode")).toEqual(["verify-full"]);
46
+ });
47
+ });
48
+
49
+ describe("pinPgSslMode — inputs left untouched", () => {
50
+ test("a URL with no sslmode", () => {
51
+ expect(pinPgSslMode(BASE)).toBe(BASE);
52
+ });
53
+
54
+ for (const mode of ["disable", "verify-full", "no-verify"]) {
55
+ test(`sslmode=${mode}`, () => {
56
+ const input = `${BASE}?sslmode=${mode}`;
57
+ expect(pinPgSslMode(input)).toBe(input);
58
+ });
59
+ }
60
+
61
+ test("an explicit uselibpqcompat=true opt-in", () => {
62
+ const input = `${BASE}?uselibpqcompat=true&sslmode=require`;
63
+ expect(pinPgSslMode(input)).toBe(input);
64
+ });
65
+
66
+ test("a string that is not a URL", () => {
67
+ expect(pinPgSslMode("not a url")).toBe("not a url");
68
+ });
69
+
70
+ test("a URL-encoded password survives unchanged when no rewrite applies", () => {
71
+ const input = "postgresql://user:p%40ss%2Fw@localhost:5432/app";
72
+ expect(pinPgSslMode(input)).toBe(input);
73
+ });
74
+ });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * lib/pool-observability.ts
3
+ * Observability helpers for diagnosing pg pool starvation (Sentry SENTRY-5T).
4
+ *
5
+ * Observability only: nothing here changes pool or client behavior. Types are
6
+ * structural so this module needs no direct dependency on `pg` or `hono`.
7
+ */
8
+
9
+ import { AsyncLocalStorage } from "node:async_hooks";
10
+
11
+ // ─── Types ────────────────────────────────────────────────────────────────────
12
+
13
+ /** Minimal Clock (matches the Clock interface used across services). */
14
+ export interface Clock {
15
+ now(): Date;
16
+ }
17
+
18
+ export interface PoolLike {
19
+ totalCount: number;
20
+ idleCount: number;
21
+ waitingCount: number;
22
+ options?: { max?: number };
23
+ }
24
+
25
+ export interface PoolSnapshot {
26
+ total: number;
27
+ idle: number;
28
+ waiting: number;
29
+ max: number | null;
30
+ }
31
+
32
+ export type PoolLog = (message: string, data?: Record<string, unknown>) => void;
33
+
34
+ type ReleaseFn = (err?: Error | boolean) => void;
35
+
36
+ export interface CheckoutPool extends PoolLike {
37
+ // biome-ignore lint/suspicious/noExplicitAny: matches pg's overloaded connect()
38
+ connect: (...args: any[]) => any;
39
+ }
40
+
41
+ const systemClock: Clock = { now: () => new Date() };
42
+
43
+ // ─── Snapshot / error detection ───────────────────────────────────────────────
44
+
45
+ export function snapshotPool(pool: PoolLike): PoolSnapshot {
46
+ return {
47
+ total: pool.totalCount,
48
+ idle: pool.idleCount,
49
+ waiting: pool.waitingCount,
50
+ max: pool.options?.max ?? null,
51
+ };
52
+ }
53
+
54
+ /** True for Prisma P2028, "Unable to start a transaction", or pg connection timeouts. */
55
+ export function isPoolTimeoutError(err: unknown): boolean {
56
+ if (!err || typeof err !== "object") return false;
57
+ const { code, message } = err as { code?: unknown; message?: unknown };
58
+ if (code === "P2028") return true;
59
+ if (typeof message !== "string") return false;
60
+ return (
61
+ message.includes("Unable to start a transaction") ||
62
+ /timeout exceeded when trying to connect/i.test(message) ||
63
+ /connection timeout/i.test(message)
64
+ );
65
+ }
66
+
67
+ // ─── Monitor ──────────────────────────────────────────────────────────────────
68
+
69
+ export interface PoolMonitorOptions {
70
+ intervalMs?: number;
71
+ log?: PoolLog;
72
+ setInterval?: (fn: () => void, ms: number) => unknown;
73
+ clearInterval?: (handle: unknown) => void;
74
+ }
75
+
76
+ /** Periodically logs the pool snapshot, only while clients are waiting. Returns a stop fn. */
77
+ export function startPoolMonitor(
78
+ pool: PoolLike,
79
+ opts: PoolMonitorOptions = {},
80
+ ): () => void {
81
+ const {
82
+ intervalMs = 30_000,
83
+ log = (m, d) => console.warn(m, d),
84
+ setInterval: setIntervalFn = (fn, ms) => setInterval(fn, ms),
85
+ clearInterval: clearIntervalFn = (h) =>
86
+ clearInterval(h as ReturnType<typeof setInterval>),
87
+ } = opts;
88
+ const handle = setIntervalFn(() => {
89
+ const snap = snapshotPool(pool);
90
+ if (snap.waiting > 0) log("pg pool has waiting clients", { ...snap });
91
+ }, intervalMs);
92
+ return () => clearIntervalFn(handle);
93
+ }
94
+
95
+ // ─── Route context ────────────────────────────────────────────────────────────
96
+
97
+ export interface RouteContext {
98
+ method: string;
99
+ route: string;
100
+ }
101
+
102
+ const routeStorage = new AsyncLocalStorage<RouteContext>();
103
+
104
+ export function runWithRouteContext<T>(ctx: RouteContext, fn: () => T): T {
105
+ return routeStorage.run(ctx, fn);
106
+ }
107
+
108
+ export function currentRouteContext(): RouteContext | undefined {
109
+ return routeStorage.getStore();
110
+ }
111
+
112
+ interface HonoLikeContext {
113
+ req: {
114
+ method: string;
115
+ path: string;
116
+ matchedRoutes?: { path: string }[];
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Hono middleware factory recording the request method and matched route
122
+ * pattern. The pattern is resolved lazily (at read time) so it reflects the
123
+ * handler route rather than the wildcard this middleware is mounted on.
124
+ */
125
+ export function routeContextMiddleware() {
126
+ return async (c: HonoLikeContext, next: () => Promise<void>) => {
127
+ const ctx: RouteContext = {
128
+ method: c.req.method,
129
+ get route() {
130
+ const matched = c.req.matchedRoutes;
131
+ return matched?.length
132
+ ? (matched[matched.length - 1] as { path: string }).path
133
+ : c.req.path;
134
+ },
135
+ };
136
+ await runWithRouteContext(ctx, next);
137
+ };
138
+ }
139
+
140
+ // ─── Connection hold tracking ─────────────────────────────────────────────────
141
+
142
+ export interface TrackHoldOptions {
143
+ thresholdMs?: number;
144
+ log?: PoolLog;
145
+ clock?: Clock;
146
+ }
147
+
148
+ /**
149
+ * Wraps `pool.connect()` so each checked-out client's `release` logs when the
150
+ * connection was held longer than `thresholdMs`. Release semantics are
151
+ * preserved exactly: args are forwarded untouched and the underlying release
152
+ * is invoked on every call (so pg's own double-release error still surfaces).
153
+ * Returns a function restoring the original `connect`.
154
+ */
155
+ export function trackConnectionHold(
156
+ pool: CheckoutPool,
157
+ opts: TrackHoldOptions = {},
158
+ ): () => void {
159
+ const {
160
+ thresholdMs = 2000,
161
+ log = (m, d) => console.warn(m, d),
162
+ clock = systemClock,
163
+ } = opts;
164
+ const originalConnect = pool.connect;
165
+
166
+ const wrap = (client: { release: ReleaseFn }) => {
167
+ const checkedOutAt = clock.now().getTime();
168
+ const route = currentRouteContext();
169
+ const originalRelease = client.release;
170
+ let reported = false;
171
+ client.release = function (this: unknown, ...args: [(Error | boolean)?]) {
172
+ if (!reported) {
173
+ reported = true;
174
+ const heldMs = clock.now().getTime() - checkedOutAt;
175
+ if (heldMs > thresholdMs) {
176
+ log("pg connection held past threshold", {
177
+ route: route ? `${route.method} ${route.route}` : null,
178
+ heldMs,
179
+ pool: snapshotPool(pool),
180
+ });
181
+ }
182
+ }
183
+ return originalRelease.apply(client, args);
184
+ } as ReleaseFn;
185
+ return client;
186
+ };
187
+
188
+ pool.connect = function (this: unknown, ...args: unknown[]) {
189
+ const result = originalConnect.apply(pool, args);
190
+ // Callback-style connect(cb) is passed through untracked.
191
+ if (typeof args[0] === "function") return result;
192
+ return (result as Promise<{ release: ReleaseFn }>).then(wrap);
193
+ };
194
+
195
+ return () => {
196
+ pool.connect = originalConnect;
197
+ };
198
+ }
199
+
200
+ // ─── Sentry context ───────────────────────────────────────────────────────────
201
+
202
+ /** Object to attach to Sentry events (e.g. `scope.setContext("pg_pool", ...)`). */
203
+ export function poolErrorContext(pool: PoolLike): Record<string, unknown> {
204
+ const route = currentRouteContext();
205
+ return {
206
+ ...snapshotPool(pool),
207
+ route: route ? `${route.method} ${route.route}` : null,
208
+ };
209
+ }