Skip to content

Commit 0ad123a

Browse files
chargomeclaude
andcommitted
test(node): Add integration tests for the Neon serverless driver
Run both Neon drivers against Neon's own proxy in front of a local Postgres, so the HTTP and WebSocket wire protocols are the real ones. Covers queries, tagged templates, batches, failing statements and pool queries in ESM and CJS under the default span lifecycle. ws is needed for the WebSocket driver on Node 20, which has no global WebSocket. Refs #25144 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent cd4d0a3 commit 0ad123a

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)