From 59ccb7085d7a24d2e59ce26fbcab0195c4d4b5d0 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 8 Oct 2026 11:11:40 +0200 Subject: [PATCH] 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 --- .../node-suites/excludes.ts | 1 + .../node-integration-tests/package.json | 2 + .../suites/tracing/neon/docker-compose.yml | 43 ++++++ .../suites/tracing/neon/instrument.mjs | 9 ++ .../suites/tracing/neon/scenario-http.mjs | 31 ++++ .../suites/tracing/neon/scenario-ws.mjs | 46 ++++++ .../suites/tracing/neon/test.ts | 146 ++++++++++++++++++ 7 files changed, 278 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/neon/docker-compose.yml create mode 100644 dev-packages/node-integration-tests/suites/tracing/neon/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/neon/scenario-http.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/neon/scenario-ws.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/neon/test.ts diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index d27790a79a0f..9cb1f8448ad3 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -120,6 +120,7 @@ export const NO_AUTO_INSTRUMENTATION = [ 'suites/tracing/mysql/test.ts', 'suites/tracing/mysql2-tracing-channel/test.ts', 'suites/tracing/mysql2/test.ts', + 'suites/tracing/neon/test.ts', 'suites/tracing/openai/test.ts', 'suites/tracing/openai/v6/test.ts', 'suites/tracing/openai/v7/test.ts', diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 18f95052d740..3dabdcc421dd 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -104,9 +104,11 @@ "rxjs": "^7.8.2", "tedious": "^19.2.1", "winston": "^3.17.0", + "ws": "^8.20.1", "yargs": "^16.2.0" }, "devDependencies": { + "@neondatabase/serverless": "1.2.0", "@opentelemetry/instrumentation-http": "0.220.0", "@sentry/conventions": "0.26.0", "@sentry-internal/test-utils": "11.5.0", diff --git a/dev-packages/node-integration-tests/suites/tracing/neon/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/neon/docker-compose.yml new file mode 100644 index 000000000000..49ce9976e3e2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/neon/docker-compose.yml @@ -0,0 +1,43 @@ +version: '3.9' + +services: + db: + image: postgres:16 + restart: always + ports: + - '5496:5432' + environment: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: tests + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U test -d tests'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s + + # Neon's own proxy binary in front of the local Postgres. It serves the HTTP driver on `/sql` + # and the WebSocket driver on `/v2`, both on port 4444, so the scenarios run the real + # `@neondatabase/serverless` wire protocols against a throwaway database. + neon-proxy: + image: ghcr.io/timowilhelm/local-neon-http-proxy:main + restart: always + ports: + - '4494:4444' + environment: + PG_CONNECTION_STRING: postgres://test:test@db:5432/tests + depends_on: + db: + condition: service_healthy + healthcheck: + test: + [ + 'CMD-SHELL', + 'curl -sf -X POST http://localhost:4444/sql -H "Neon-Connection-String: + postgres://test:test@db.localtest.me:5432/tests" -d ''{"query":"SELECT 1","params":[]}''', + ] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/dev-packages/node-integration-tests/suites/tracing/neon/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/neon/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/neon/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/neon/scenario-http.mjs b/dev-packages/node-integration-tests/suites/tracing/neon/scenario-http.mjs new file mode 100644 index 000000000000..938139f7d7cd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/neon/scenario-http.mjs @@ -0,0 +1,31 @@ +import * as Sentry from '@sentry/node'; +import { waitForConnection } from '@sentry-internal/node-integration-tests'; +import { neon, neonConfig } from '@neondatabase/serverless'; + +neonConfig.fetchEndpoint = 'http://localhost:4494/sql'; + +const sql = neon('postgres://test:test@db.localtest.me:5432/tests'); + +async function run() { + await waitForConnection(() => sql`SELECT 1`); + + await Sentry.startSpan({ name: 'Test Span', op: 'test' }, async () => { + try { + 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"))`; + + await sql.query('INSERT INTO "User" ("email", "name") VALUES ($1, $2)', ['tim@domain.com', 'tim']); + await sql`SELECT * FROM "User" WHERE "name" = ${'tim'}`; + + await sql.transaction([sql`SELECT "email" FROM "User"`, sql`SELECT "name" FROM "User"`]); + + // A failing query should still produce an errored span + await sql`SELECT * FROM "does_not_exist_table"`.catch(() => { + // swallow: we only care about the span it produces + }); + } finally { + await sql`DROP TABLE "User"`; + } + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/neon/scenario-ws.mjs b/dev-packages/node-integration-tests/suites/tracing/neon/scenario-ws.mjs new file mode 100644 index 000000000000..bd71ff24f050 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/neon/scenario-ws.mjs @@ -0,0 +1,46 @@ +import * as Sentry from '@sentry/node'; +import { waitForConnection } from '@sentry-internal/node-integration-tests'; +import { Client, Pool, neonConfig } from '@neondatabase/serverless'; +import ws from 'ws'; + +neonConfig.webSocketConstructor = ws; +neonConfig.wsProxy = () => 'localhost:4494/v2'; +neonConfig.useSecureWebSocket = false; +neonConfig.pipelineConnect = false; + +const connectionString = 'postgres://test:test@db.localtest.me:5432/tests'; +const client = new Client(connectionString); + +async function run() { + await waitForConnection(async () => { + const probe = new Client(connectionString); + await probe.connect(); + await probe.end(); + }); + + await Sentry.startSpan({ name: 'Test Span', op: 'test' }, async () => { + const pool = new Pool({ connectionString }); + try { + await client.connect(); + + await client.query( + '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"))', + ); + await client.query('INSERT INTO "User" ("email", "name") VALUES ($1, $2)', ['tim@domain.com', 'tim']); + await client.query('SELECT * FROM "User"'); + + await pool.query('SELECT "name" FROM "User"'); + + // A failing query should still produce an errored span + await client.query('SELECT * FROM "does_not_exist_table"').catch(() => { + // swallow: we only care about the span it produces + }); + } finally { + await client.query('DROP TABLE "User"'); + await client.end(); + await pool.end(); + } + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/neon/test.ts b/dev-packages/node-integration-tests/suites/tracing/neon/test.ts new file mode 100644 index 000000000000..64a77d3ccec9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/neon/test.ts @@ -0,0 +1,146 @@ +import type { SerializedStreamedSpan } from '@sentry/core'; +import { + DB_NAMESPACE, + DB_OPERATION_BATCH_SIZE, + DB_QUERY_SUMMARY, + DB_QUERY_TEXT, + DB_SYSTEM_NAME, + DB_USER, + SENTRY_KIND, + SENTRY_OP, + SENTRY_ORIGIN, + SERVER_ADDRESS, + SERVER_PORT, +} from '@sentry/conventions/attributes'; +import { DB } from '@sentry/conventions/op'; +import { afterAll, describe, expect } from 'vitest'; +import { RUNTIME } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; + +const ORIGIN = 'auto.db.neon'; + +const CREATE_USER_TABLE_STATEMENT = + '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"))'; + +function attr(value: unknown, type = 'string'): { type: string; value: unknown } { + return { type, value }; +} + +// Both drivers report the same connection attributes: the WebSocket driver from pg's +// `connectionParameters`, the HTTP driver from the resolved connection URL. +const CONNECTION_ATTRIBUTES = { + [DB_SYSTEM_NAME]: attr('postgresql'), + [DB_NAMESPACE]: attr('tests'), + [DB_USER]: attr('test'), + [SERVER_ADDRESS]: attr('db.localtest.me'), + [SERVER_PORT]: attr(5432, 'integer'), +}; + +function expectedDbSpan({ + name, + statement, + status = 'ok', + batchSize, +}: { + name: string; + statement: string; + status?: 'ok' | 'error'; + batchSize?: number; +}): unknown { + return expect.objectContaining({ + attributes: expect.objectContaining({ + ...CONNECTION_ATTRIBUTES, + [SENTRY_OP]: attr(DB), + [SENTRY_KIND]: attr('client'), + [SENTRY_ORIGIN]: attr(ORIGIN), + [DB_QUERY_TEXT]: attr(statement), + [DB_QUERY_SUMMARY]: attr(name), + ...(batchSize ? { [DB_OPERATION_BATCH_SIZE]: attr(batchSize, 'integer') } : {}), + }), + is_segment: false, + name, + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + status, + }); +} + +function getNeonSpans(spans: SerializedStreamedSpan[]): SerializedStreamedSpan[] { + const neonSpans = spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === ORIGIN); + // `server.address`/`server.port` describe the connection; the deprecated string is not emitted. + expect(neonSpans.some(span => 'db.connection_string' in span.attributes)).toBe(false); + return neonSpans; +} + +describeWithDockerCompose('neon auto instrumentation', { workingDirectory: [__dirname] }, () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe('http driver', () => { + createEsmAndCjsTests(__dirname, 'scenario-http.mjs', 'instrument.mjs', (createTestRunner, test) => { + test('instruments `neon()` queries, templates and transactions', { timeout: 90_000 }, async () => { + const runner = createTestRunner(); + const spansPromise = runner.collectStreamedSpansUntilSegment('Test Span'); + + await runner.start().completed(); + + const spans = await spansPromise; + const segment = spans.find(span => span.is_segment && span.name === 'Test Span'); + expect(segment).toBeDefined(); + + const neonSpans = getNeonSpans(spans); + expect(neonSpans.every(span => span.parent_span_id === segment!.span_id)).toBe(true); + expect(neonSpans).toEqual([ + expectedDbSpan({ name: 'CREATE TABLE "User"', statement: CREATE_USER_TABLE_STATEMENT }), + expectedDbSpan({ name: 'INSERT "User"', statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)' }), + expectedDbSpan({ name: 'SELECT "User"', statement: 'SELECT * FROM "User" WHERE "name" = $1' }), + expectedDbSpan({ + name: 'SELECT "User"; SELECT "User"', + statement: 'SELECT "email" FROM "User"; SELECT "name" FROM "User"', + batchSize: 2, + }), + expectedDbSpan({ + name: 'SELECT "does_not_exist_table"', + statement: 'SELECT * FROM "does_not_exist_table"', + status: 'error', + }), + expectedDbSpan({ name: 'DROP TABLE "User"', statement: 'DROP TABLE "User"' }), + ]); + }); + }); + }); + + describe('websocket driver', () => { + createEsmAndCjsTests(__dirname, 'scenario-ws.mjs', 'instrument.mjs', (createTestRunner, test) => { + test('instruments `Client` and `Pool` queries', { timeout: 90_000 }, async () => { + const runner = createTestRunner(); + const spansPromise = runner.collectStreamedSpansUntilSegment('Test Span'); + + await runner.start().completed(); + + const spans = await spansPromise; + const segment = spans.find(span => span.is_segment && span.name === 'Test Span'); + expect(segment).toBeDefined(); + + const neonSpans = getNeonSpans(spans); + expect(neonSpans.every(span => span.parent_span_id === segment!.span_id)).toBe(true); + expect(neonSpans).toEqual([ + expectedDbSpan({ name: 'CREATE TABLE "User"', statement: CREATE_USER_TABLE_STATEMENT }), + expectedDbSpan({ name: 'INSERT "User"', statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)' }), + expectedDbSpan({ name: 'SELECT "User"', statement: 'SELECT * FROM "User"' }), + // On Bun and Deno the pool's connect callback runs outside the segment's async context, + // so the query it issues has no parent span and is skipped. + ...(RUNTIME === 'node' + ? [expectedDbSpan({ name: 'SELECT "User"', statement: 'SELECT "name" FROM "User"' })] + : []), + expectedDbSpan({ + name: 'SELECT "does_not_exist_table"', + statement: 'SELECT * FROM "does_not_exist_table"', + status: 'error', + }), + expectedDbSpan({ name: 'DROP TABLE "User"', statement: 'DROP TABLE "User"' }), + ]); + }); + }); + }); +});