Skip to content

Commit ee150a9

Browse files
committed
Implement OpenTelemetry shutdown handling and enhance database connection management
- Added `shutdownOTel` function to gracefully shut down OpenTelemetry SDK during process termination. - Updated `prisma-client.tsx` to call `shutdownOTel` on SIGTERM, ensuring proper cleanup of background tasks and database connections. - Improved pool max configuration logic for PostgreSQL client to handle invalid values more robustly. - Enhanced trusted proxy handling in `end-users.tsx` to include "cloudrun" as a valid option. These changes improve observability and resource management in the backend, particularly for Cloud Run deployments.
1 parent 42747da commit ee150a9

4 files changed

Lines changed: 76 additions & 20 deletions

File tree

apps/backend/src/instrumentation.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ function getDevTraceExporter() {
3232
return undefined;
3333
}
3434

35+
let otelSdk: { shutdown(): Promise<void> } | undefined;
36+
37+
export async function shutdownOTel() {
38+
await otelSdk?.shutdown();
39+
}
40+
3541
async function registerOTelProvider() {
3642
const instrumentations = getOTelInstrumentations();
3743
const devExporter = getDevTraceExporter();
@@ -44,8 +50,8 @@ async function registerOTelProvider() {
4450
instrumentations,
4551
...devExporter ? { traceExporter: devExporter } : {},
4652
});
47-
} else {
48-
// On Cloud Run / self-hosted: use standard @opentelemetry/sdk-node
53+
} else if (getNextRuntime() === "nodejs") {
54+
// On Cloud Run / self-hosted: use standard @opentelemetry/sdk-node (Node.js only)
4955
const { NodeSDK } = await import("@opentelemetry/sdk-node");
5056
const otelEndpoint = getEnvVariable("OTEL_EXPORTER_OTLP_ENDPOINT", "");
5157
const exporter = devExporter ?? (otelEndpoint ? new OTLPTraceExporter({ url: otelEndpoint }) : undefined);
@@ -57,6 +63,7 @@ async function registerOTelProvider() {
5763
...(exporter ? { traceExporter: exporter as any } : {}),
5864
});
5965
sdk.start();
66+
otelSdk = sdk;
6067
}
6168
}
6269

apps/backend/src/lib/end-users.tsx

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ export async function getEndUserInfo(): Promise<
174174
if (isClaimingToBeBrowser) {
175175
// Determine which proxy we trust based on deployment configuration.
176176
// These headers can only be trusted when the origin is exclusively reachable through the proxy;
177-
// STACK_TRUSTED_PROXY should be set to "vercel", "cloudflare", or left empty/unset for no proxy trust.
177+
// STACK_TRUSTED_PROXY should be set to "vercel", "cloudflare", "cloudrun", or left empty/unset for no proxy trust.
178178
const trustedProxy = getEnvVariable("STACK_TRUSTED_PROXY", "").toLowerCase().trim();
179179
if (trustedProxy !== "" && trustedProxy !== "vercel" && trustedProxy !== "cloudflare" && trustedProxy !== "cloudrun") {
180180
throw new StackAssertionError(`STACK_TRUSTED_PROXY must be "vercel", "cloudflare", "cloudrun", or empty/unset, but got: "${trustedProxy}"`);
@@ -227,6 +227,51 @@ import.meta.vitest?.describe("getBrowserEndUserInfo(...)", () => {
227227
});
228228
});
229229

230+
test("trusts first x-forwarded-for entry when Cloud Run proxy is configured", () => {
231+
const result = getBrowserEndUserInfo(new Headers({
232+
"user-agent": "Mozilla/5.0",
233+
"x-forwarded-for": "198.51.100.42, 10.0.0.1",
234+
}), "cloudrun");
235+
236+
expect(result).toEqual({
237+
maybeSpoofed: false,
238+
exactInfo: {
239+
ip: "198.51.100.42",
240+
},
241+
});
242+
});
243+
244+
test("does not expose x-forwarded-for as spoofable when Cloud Run proxy is configured", () => {
245+
const result = getBrowserEndUserInfo(new Headers({
246+
"user-agent": "Mozilla/5.0",
247+
"x-forwarded-for": "198.51.100.42",
248+
"x-real-ip": "10.0.0.1",
249+
}), "cloudrun");
250+
251+
expect(result).toEqual({
252+
maybeSpoofed: false,
253+
exactInfo: {
254+
ip: "198.51.100.42",
255+
},
256+
});
257+
});
258+
259+
test("does not trust geo headers for Cloud Run proxy", () => {
260+
const result = getBrowserEndUserInfo(new Headers({
261+
"user-agent": "Mozilla/5.0",
262+
"x-forwarded-for": "198.51.100.42",
263+
"x-vercel-ip-country": "US",
264+
"cf-ipcountry": "DE",
265+
}), "cloudrun");
266+
267+
expect(result).toEqual({
268+
maybeSpoofed: false,
269+
exactInfo: {
270+
ip: "198.51.100.42",
271+
},
272+
});
273+
});
274+
230275
test("keeps trusted proxy geo headers when the trusted IP header is present", () => {
231276
const result = getBrowserEndUserInfo(new Headers({
232277
"user-agent": "Mozilla/5.0",

apps/backend/src/prisma-client.tsx

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist
1010
import { globalVar } from "@stackframe/stack-shared/dist/utils/globals";
1111
import { deepPlainEquals, filterUndefined, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
1212
import { concatStacktracesIfRejected, ignoreUnhandledRejection, runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/promises";
13+
import { drainInFlightPromises } from "./utils/background-tasks";
14+
import { shutdownOTel } from "./instrumentation";
1315
import { throwingProxy } from "@stackframe/stack-shared/dist/utils/proxies";
1416
import { Result } from "@stackframe/stack-shared/dist/utils/results";
1517
import { traceSpan } from "@stackframe/stack-shared/dist/utils/telemetry";
@@ -84,7 +86,8 @@ function getPostgresPrismaClient(connectionString: string, poolLabel?: string) {
8486
let postgresPrismaClient = postgresPrismaClientsStore.get(connectionString);
8587
if (!postgresPrismaClient) {
8688
const schema = getSchemaFromConnectionString(connectionString);
87-
const poolMax = parseInt(getEnvVariable("STACK_DATABASE_POOL_MAX", "25"));
89+
const poolMaxRaw = parseInt(getEnvVariable("STACK_DATABASE_POOL_MAX", "25"), 10);
90+
const poolMax = Number.isFinite(poolMaxRaw) && poolMaxRaw > 0 ? poolMaxRaw : 25;
8891
const pool = new Pool({ connectionString, max: poolMax });
8992
pool.on('error', (err) => {
9093
// Prevent unhandled rejections from crashing the process (e.g. on Cloud Run)
@@ -102,24 +105,20 @@ function getPostgresPrismaClient(connectionString: string, poolLabel?: string) {
102105
}
103106

104107
// Graceful shutdown for non-Vercel runtimes (Cloud Run sends SIGTERM before shutdown)
105-
if (!getEnvVariable("VERCEL", "")) {
108+
if (!getEnvVariable("VERCEL", "") && !globalVar.__stack_prisma_sigterm_registered) {
109+
globalVar.__stack_prisma_sigterm_registered = true;
106110
process.on("SIGTERM", () => {
107111
runAsynchronously(async () => {
108112
console.log("[SIGTERM] Draining background tasks and database connections...");
109-
try {
110-
const { drainInFlightPromises } = await import("./utils/vercel");
111-
await drainInFlightPromises(8000);
112-
} catch {
113-
// vercel utils may not be available in all contexts
114-
}
113+
await drainInFlightPromises(8000);
114+
await shutdownOTel();
115115
for (const [, entry] of postgresPrismaClientsStore) {
116-
await entry.client.$disconnect().catch(() => {});
116+
await entry.client.$disconnect();
117117
}
118118
for (const [, client] of prismaClientsStore.neon) {
119-
await client.$disconnect().catch(() => {});
119+
await client.$disconnect();
120120
}
121-
console.log("[SIGTERM] Shutdown complete.");
122-
process.exit(0);
121+
console.log("[SIGTERM] Completed draining background tasks and database connections.");
123122
});
124123
});
125124
}

packages/stack-shared/src/helpers/vault/server-side.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,17 @@ async function fetchGcpIdToken(audience: string): Promise<string> {
6161
return await response.text();
6262
}
6363

64+
let kmsClientCache: KMSClient | undefined;
65+
6466
async function getKmsClient() {
65-
return new KMSClient({
66-
region: getEnvVariable("STACK_AWS_REGION"),
67-
endpoint: getEnvVariable("STACK_AWS_KMS_ENDPOINT"),
68-
credentials: await getAwsCredentials(),
69-
});
67+
if (!kmsClientCache) {
68+
kmsClientCache = new KMSClient({
69+
region: getEnvVariable("STACK_AWS_REGION"),
70+
endpoint: getEnvVariable("STACK_AWS_KMS_ENDPOINT"),
71+
credentials: await getAwsCredentials(),
72+
});
73+
}
74+
return kmsClientCache;
7075
}
7176

7277
async function getOrCreateKekId(): Promise<string> {

0 commit comments

Comments
 (0)