Skip to content

Commit cb88771

Browse files
chargomeclaude
andauthored
test(node): Add integration tests for the Neon serverless driver (#25177)
Runs both Neon drivers end to end against Neon's own proxy (`ghcr.io/timowilhelm/local-neon-http-proxy`) in front of a local Postgres, so the HTTP `/sql` and WebSocket `/v2` wire protocols are the real ones. Covers `sql.query()`, tagged templates, `transaction()` batches, a failing statement and pool queries, in ESM and CJS under the default span lifecycle. The suite is excluded on Bun like the other database suites, since `bun run` cannot inject the diagnostics channels; the Bun plugin build does run it. The pool test is Node only, because on Bun and Deno the pool's connect callback runs outside the segment's async context. `ws` is added because Node 20 has no global `WebSocket`. Stacked on #25164. Refs #25144 Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 9f8f848 commit cb88771

9 files changed

Lines changed: 340 additions & 0 deletions

File tree

‎dev-packages/bun-integration-tests/node-suites/excludes.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export const NO_AUTO_INSTRUMENTATION = [
120120
'suites/tracing/mysql/test.ts',
121121
'suites/tracing/mysql2-tracing-channel/test.ts',
122122
'suites/tracing/mysql2/test.ts',
123+
'suites/tracing/neon/test.ts',
123124
'suites/tracing/openai/test.ts',
124125
'suites/tracing/openai/v6/test.ts',
125126
'suites/tracing/openai/v7/test.ts',

‎dev-packages/node-integration-tests/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,11 @@
104104
"rxjs": "^7.8.2",
105105
"tedious": "^19.2.1",
106106
"winston": "^3.17.0",
107+
"ws": "^8.20.1",
107108
"yargs": "^16.2.0"
108109
},
109110
"devDependencies": {
111+
"@neondatabase/serverless": "1.2.0",
110112
"@opentelemetry/instrumentation-http": "0.220.0",
111113
"@sentry/conventions": "0.26.0",
112114
"@sentry-internal/test-utils": "11.6.0",
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
version: '3.9'
2+
3+
services:
4+
db:
5+
image: postgres:16
6+
restart: always
7+
ports:
8+
- '5496:5432'
9+
environment:
10+
POSTGRES_USER: test
11+
POSTGRES_PASSWORD: test
12+
POSTGRES_DB: tests
13+
healthcheck:
14+
test: ['CMD-SHELL', 'pg_isready -U test -d tests']
15+
interval: 2s
16+
timeout: 3s
17+
retries: 30
18+
start_period: 5s
19+
20+
# Neon's own proxy binary in front of the local Postgres. It serves the HTTP driver on `/sql`
21+
# and the WebSocket driver on `/v2`, both on port 4444, so the scenarios run the real
22+
# `@neondatabase/serverless` wire protocols against a throwaway database.
23+
neon-proxy:
24+
image: ghcr.io/timowilhelm/local-neon-http-proxy:main
25+
restart: always
26+
ports:
27+
- '4494:4444'
28+
environment:
29+
PG_CONNECTION_STRING: postgres://test:test@db:5432/tests
30+
depends_on:
31+
db:
32+
condition: service_healthy
33+
healthcheck:
34+
test:
35+
[
36+
'CMD-SHELL',
37+
'curl -sf -X POST http://localhost:4444/sql -H "Neon-Connection-String:
38+
postgres://test:test@db.localtest.me:5432/tests" -d ''{"query":"SELECT 1","params":[]}''',
39+
]
40+
interval: 2s
41+
timeout: 3s
42+
retries: 30
43+
start_period: 5s
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import * as Sentry from '@sentry/node';
2+
import { waitForConnection } from '@sentry-internal/node-integration-tests';
3+
import { neon, neonConfig } from '@neondatabase/serverless';
4+
5+
neonConfig.fetchEndpoint = 'http://localhost:4494/sql';
6+
7+
// The connection string is never dialed by this process: the drivers only talk to the proxy, which
8+
// connects to Postgres itself. Host and port here are what the spans report as the server.
9+
const sql = neon('postgres://test:test@db.localtest.me:5432/tests');
10+
11+
async function run() {
12+
await waitForConnection(() => sql`SELECT 1`);
13+
14+
await Sentry.startSpan({ name: 'Test Span', op: 'test' }, async () => {
15+
try {
16+
await sql`CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))`;
17+
18+
await sql.query('INSERT INTO "User" ("email", "name") VALUES ($1, $2)', ['tim@domain.com', 'tim']);
19+
await sql`SELECT * FROM "User" WHERE "name" = ${'tim'}`;
20+
21+
await sql.transaction([sql`SELECT "email" FROM "User"`, sql`SELECT "name" FROM "User"`]);
22+
23+
// A failing query should still produce an errored span
24+
await sql`SELECT * FROM "does_not_exist_table"`.catch(() => {
25+
// swallow: we only care about the span it produces
26+
});
27+
} finally {
28+
await sql`DROP TABLE "User"`;
29+
}
30+
});
31+
}
32+
33+
run();
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as Sentry from '@sentry/node';
2+
import { waitForConnection } from '@sentry-internal/node-integration-tests';
3+
import { Client, Pool, neonConfig } from '@neondatabase/serverless';
4+
import ws from 'ws';
5+
6+
neonConfig.webSocketConstructor = ws;
7+
neonConfig.wsProxy = () => 'localhost:4494/v2';
8+
neonConfig.useSecureWebSocket = false;
9+
neonConfig.pipelineConnect = false;
10+
11+
// The connection string is never dialed by this process: the drivers only talk to the proxy, which
12+
// connects to Postgres itself. Host and port here are what the spans report as the server.
13+
const connectionString = 'postgres://test:test@db.localtest.me:5432/tests';
14+
15+
async function run() {
16+
await waitForConnection(async () => {
17+
const probe = new Client(connectionString);
18+
await probe.connect();
19+
await probe.end();
20+
});
21+
22+
const pool = new Pool({ connectionString });
23+
await Sentry.startSpan({ name: 'Test Span', op: 'test' }, async () => {
24+
try {
25+
await pool.query('SELECT "email", "name" FROM pg_catalog.pg_user LIMIT 1').catch(() => {
26+
// swallow: pg_user has no "email" column, the span is what matters
27+
});
28+
await pool.query('SELECT "usename" FROM pg_catalog.pg_user');
29+
} finally {
30+
await pool.end();
31+
}
32+
});
33+
}
34+
35+
run();
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import * as Sentry from '@sentry/node';
2+
import { waitForConnection } from '@sentry-internal/node-integration-tests';
3+
import { Client, neonConfig } from '@neondatabase/serverless';
4+
import ws from 'ws';
5+
6+
neonConfig.webSocketConstructor = ws;
7+
neonConfig.wsProxy = () => 'localhost:4494/v2';
8+
neonConfig.useSecureWebSocket = false;
9+
neonConfig.pipelineConnect = false;
10+
11+
// The connection string is never dialed by this process: the drivers only talk to the proxy, which
12+
// connects to Postgres itself. Host and port here are what the spans report as the server.
13+
const connectionString = 'postgres://test:test@db.localtest.me:5432/tests';
14+
const client = new Client(connectionString);
15+
16+
async function run() {
17+
await waitForConnection(async () => {
18+
const probe = new Client(connectionString);
19+
await probe.connect();
20+
await probe.end();
21+
});
22+
23+
await Sentry.startSpan({ name: 'Test Span', op: 'test' }, async () => {
24+
try {
25+
await client.connect();
26+
27+
await client.query(
28+
'CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))',
29+
);
30+
await client.query('INSERT INTO "User" ("email", "name") VALUES ($1, $2)', ['tim@domain.com', 'tim']);
31+
await client.query('SELECT * FROM "User"');
32+
33+
// A failing query should still produce an errored span
34+
await client.query('SELECT * FROM "does_not_exist_table"').catch(() => {
35+
// swallow: we only care about the span it produces
36+
});
37+
} finally {
38+
await client.query('DROP TABLE "User"');
39+
await client.end();
40+
}
41+
});
42+
}
43+
44+
run();
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import type { SerializedStreamedSpan } from '@sentry/core';
2+
import {
3+
DB_NAMESPACE,
4+
DB_OPERATION_BATCH_SIZE,
5+
DB_QUERY_SUMMARY,
6+
DB_QUERY_TEXT,
7+
DB_SYSTEM_NAME,
8+
DB_USER,
9+
SENTRY_KIND,
10+
SENTRY_OP,
11+
SENTRY_ORIGIN,
12+
SERVER_ADDRESS,
13+
SERVER_PORT,
14+
} from '@sentry/conventions/attributes';
15+
import { DB } from '@sentry/conventions/op';
16+
import { afterAll, describe, expect } from 'vitest';
17+
import { RUNTIME, streamedAttribute as attr } from '../../../utils';
18+
import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner';
19+
20+
const ORIGIN = 'auto.db.neon';
21+
22+
const CREATE_USER_TABLE_STATEMENT =
23+
'CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))';
24+
25+
// Both drivers report the same connection attributes: the WebSocket driver from pg's
26+
// `connectionParameters`, the HTTP driver from the resolved connection URL.
27+
const CONNECTION_ATTRIBUTES = {
28+
[DB_SYSTEM_NAME]: attr('postgresql'),
29+
[DB_NAMESPACE]: attr('tests'),
30+
[DB_USER]: attr('test'),
31+
[SERVER_ADDRESS]: attr('db.localtest.me'),
32+
[SERVER_PORT]: attr(5432, 'integer'),
33+
};
34+
35+
function expectedDbSpan({
36+
name,
37+
statement,
38+
status = 'ok',
39+
batchSize,
40+
}: {
41+
name: string;
42+
statement: string;
43+
status?: 'ok' | 'error';
44+
batchSize?: number;
45+
}): unknown {
46+
return expect.objectContaining({
47+
attributes: expect.objectContaining({
48+
...CONNECTION_ATTRIBUTES,
49+
[SENTRY_OP]: attr(DB),
50+
[SENTRY_KIND]: attr('client'),
51+
[SENTRY_ORIGIN]: attr(ORIGIN),
52+
[DB_QUERY_TEXT]: attr(statement),
53+
[DB_QUERY_SUMMARY]: attr(name),
54+
...(batchSize ? { [DB_OPERATION_BATCH_SIZE]: attr(batchSize, 'integer') } : {}),
55+
}),
56+
is_segment: false,
57+
name,
58+
parent_span_id: expect.stringMatching(/^[\da-f]{16}$/),
59+
status,
60+
});
61+
}
62+
63+
function getNeonSpans(spans: SerializedStreamedSpan[]): SerializedStreamedSpan[] {
64+
const neonSpans = spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === ORIGIN);
65+
// `server.address`/`server.port` describe the connection; the deprecated string is not emitted.
66+
expect(neonSpans.some(span => 'db.connection_string' in span.attributes)).toBe(false);
67+
return neonSpans;
68+
}
69+
70+
describeWithDockerCompose('neon auto instrumentation', { workingDirectory: [__dirname] }, () => {
71+
afterAll(() => {
72+
cleanupChildProcesses();
73+
});
74+
75+
describe('http driver', () => {
76+
createEsmAndCjsTests(__dirname, 'scenario-http.mjs', 'instrument.mjs', (createTestRunner, test) => {
77+
test('instruments `neon()` queries, templates and transactions', { timeout: 90_000 }, async () => {
78+
const runner = createTestRunner();
79+
const spansPromise = runner.collectStreamedSpansUntilSegment('Test Span');
80+
81+
await runner.start().completed();
82+
83+
const spans = await spansPromise;
84+
const segment = spans.find(span => span.is_segment && span.name === 'Test Span');
85+
expect(segment).toBeDefined();
86+
87+
const neonSpans = getNeonSpans(spans);
88+
expect(neonSpans.every(span => span.parent_span_id === segment!.span_id)).toBe(true);
89+
expect(neonSpans).toEqual([
90+
expectedDbSpan({ name: 'CREATE TABLE "User"', statement: CREATE_USER_TABLE_STATEMENT }),
91+
expectedDbSpan({ name: 'INSERT "User"', statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)' }),
92+
expectedDbSpan({ name: 'SELECT "User"', statement: 'SELECT * FROM "User" WHERE "name" = $1' }),
93+
expectedDbSpan({
94+
name: 'SELECT "User"; SELECT "User"',
95+
statement: 'SELECT "email" FROM "User"; SELECT "name" FROM "User"',
96+
batchSize: 2,
97+
}),
98+
expectedDbSpan({
99+
name: 'SELECT "does_not_exist_table"',
100+
statement: 'SELECT * FROM "does_not_exist_table"',
101+
status: 'error',
102+
}),
103+
expectedDbSpan({ name: 'DROP TABLE "User"', statement: 'DROP TABLE "User"' }),
104+
]);
105+
});
106+
});
107+
});
108+
109+
describe('websocket driver', () => {
110+
createEsmAndCjsTests(__dirname, 'scenario-ws.mjs', 'instrument.mjs', (createTestRunner, test) => {
111+
test('instruments `Client` queries', { timeout: 90_000 }, async () => {
112+
const runner = createTestRunner();
113+
const spansPromise = runner.collectStreamedSpansUntilSegment('Test Span');
114+
115+
await runner.start().completed();
116+
117+
const spans = await spansPromise;
118+
const segment = spans.find(span => span.is_segment && span.name === 'Test Span');
119+
expect(segment).toBeDefined();
120+
121+
const neonSpans = getNeonSpans(spans);
122+
expect(neonSpans.every(span => span.parent_span_id === segment!.span_id)).toBe(true);
123+
expect(neonSpans).toEqual([
124+
expectedDbSpan({ name: 'CREATE TABLE "User"', statement: CREATE_USER_TABLE_STATEMENT }),
125+
expectedDbSpan({ name: 'INSERT "User"', statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)' }),
126+
expectedDbSpan({ name: 'SELECT "User"', statement: 'SELECT * FROM "User"' }),
127+
expectedDbSpan({
128+
name: 'SELECT "does_not_exist_table"',
129+
statement: 'SELECT * FROM "does_not_exist_table"',
130+
status: 'error',
131+
}),
132+
expectedDbSpan({ name: 'DROP TABLE "User"', statement: 'DROP TABLE "User"' }),
133+
]);
134+
});
135+
});
136+
});
137+
138+
describe('websocket pool', () => {
139+
createEsmAndCjsTests(__dirname, 'scenario-pool.mjs', 'instrument.mjs', (createTestRunner, test) => {
140+
// On Bun and Deno the pool's connect callback runs outside the segment's async context, so
141+
// the queries it issues have no parent span.
142+
test.skipIf(RUNTIME !== 'node')('instruments `Pool` queries', { timeout: 90_000 }, async () => {
143+
const runner = createTestRunner();
144+
const spansPromise = runner.collectStreamedSpansUntilSegment('Test Span');
145+
146+
await runner.start().completed();
147+
148+
const spans = await spansPromise;
149+
const segment = spans.find(span => span.is_segment && span.name === 'Test Span');
150+
expect(segment).toBeDefined();
151+
152+
const neonSpans = getNeonSpans(spans);
153+
expect(neonSpans.every(span => span.parent_span_id === segment!.span_id)).toBe(true);
154+
expect(neonSpans).toEqual([
155+
expectedDbSpan({
156+
name: 'SELECT pg_catalog.pg_user',
157+
statement: 'SELECT "email", "name" FROM pg_catalog.pg_user LIMIT ?',
158+
status: 'error',
159+
}),
160+
expectedDbSpan({ name: 'SELECT pg_catalog.pg_user', statement: 'SELECT "usename" FROM pg_catalog.pg_user' }),
161+
]);
162+
});
163+
});
164+
});
165+
});

‎dev-packages/node-integration-tests/utils/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,11 @@ export const parseEnvelope = (body: string): Array<Record<string, unknown>> => {
9898
export function getStringAttributeValue(value: unknown): string | undefined {
9999
return typeof value === 'string' ? value : undefined;
100100
}
101+
102+
/**
103+
* A serialized streamed-span attribute (`{ type, value }`), for strict `toEqual` assertions on
104+
* `attributes` of a streamed span. Defaults to a string attribute.
105+
*/
106+
export function streamedAttribute(value: unknown, type = 'string'): { type: string; value: unknown } {
107+
return { type, value };
108+
}

0 commit comments

Comments
 (0)