Repository navigation
test(node): Add integration tests for the Neon serverless driver #25177
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| 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'; | ||
|
|
||
| // The connection string is never dialed by this process: the drivers only talk to the proxy, which | ||
| // connects to Postgres itself. Host and port here are what the spans report as the server. | ||
| const sql = neon('postgres://test:test@db.localtest.me:5432/tests'); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. q: Is port 5432 exported? The docker file says
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The test never connects to that address, the drivers only talk to the proxy on 4494 and the proxy connects to the compose database itself, so the host and port in the scenario only end up in the span attributes. |
||
|
|
||
| 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(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| 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; | ||
|
|
||
| // The connection string is never dialed by this process: the drivers only talk to the proxy, which | ||
| // connects to Postgres itself. Host and port here are what the spans report as the server. | ||
| const connectionString = 'postgres://test:test@db.localtest.me:5432/tests'; | ||
|
|
||
| async function run() { | ||
| await waitForConnection(async () => { | ||
| const probe = new Client(connectionString); | ||
| await probe.connect(); | ||
| await probe.end(); | ||
| }); | ||
|
|
||
| const pool = new Pool({ connectionString }); | ||
| await Sentry.startSpan({ name: 'Test Span', op: 'test' }, async () => { | ||
| try { | ||
| await pool.query('SELECT "email", "name" FROM pg_catalog.pg_user LIMIT 1').catch(() => { | ||
| // swallow: pg_user has no "email" column, the span is what matters | ||
| }); | ||
| await pool.query('SELECT "usename" FROM pg_catalog.pg_user'); | ||
| } finally { | ||
| await pool.end(); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| run(); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import * as Sentry from '@sentry/node'; | ||
| import { waitForConnection } from '@sentry-internal/node-integration-tests'; | ||
| import { Client, neonConfig } from '@neondatabase/serverless'; | ||
| import ws from 'ws'; | ||
|
|
||
| neonConfig.webSocketConstructor = ws; | ||
| neonConfig.wsProxy = () => 'localhost:4494/v2'; | ||
| neonConfig.useSecureWebSocket = false; | ||
| neonConfig.pipelineConnect = false; | ||
|
|
||
| // The connection string is never dialed by this process: the drivers only talk to the proxy, which | ||
| // connects to Postgres itself. Host and port here are what the spans report as the server. | ||
| 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 () => { | ||
| 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"'); | ||
|
|
||
| // 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(); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| run(); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| 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, streamedAttribute as attr } 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"))'; | ||
|
|
||
| // 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` 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"' }), | ||
| 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 pool', () => { | ||
| createEsmAndCjsTests(__dirname, 'scenario-pool.mjs', 'instrument.mjs', (createTestRunner, test) => { | ||
| // On Bun and Deno the pool's connect callback runs outside the segment's async context, so | ||
| // the queries it issues have no parent span. | ||
| test.skipIf(RUNTIME !== 'node')('instruments `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: 'SELECT pg_catalog.pg_user', | ||
| statement: 'SELECT "email", "name" FROM pg_catalog.pg_user LIMIT ?', | ||
| status: 'error', | ||
| }), | ||
| expectedDbSpan({ name: 'SELECT pg_catalog.pg_user', statement: 'SELECT "usename" FROM pg_catalog.pg_user' }), | ||
| ]); | ||
| }); | ||
| }); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.