diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index 495ca29dec35..36e00ca5f35c 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 fa0e94ba095d..d5d4dd9d8f83 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.6.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..80469395a28e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/neon/scenario-http.mjs @@ -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'); + +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-pool.mjs b/dev-packages/node-integration-tests/suites/tracing/neon/scenario-pool.mjs new file mode 100644 index 000000000000..0562030fe3ce --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/neon/scenario-pool.mjs @@ -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(); 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..d4f314de0bc8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/neon/scenario-ws.mjs @@ -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(); 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..e1310a6ff703 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/neon/test.ts @@ -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' }), + ]); + }); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/utils/index.ts b/dev-packages/node-integration-tests/utils/index.ts index 21fdff8fe8a8..0849c4464bfc 100644 --- a/dev-packages/node-integration-tests/utils/index.ts +++ b/dev-packages/node-integration-tests/utils/index.ts @@ -98,3 +98,11 @@ export const parseEnvelope = (body: string): Array> => { export function getStringAttributeValue(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } + +/** + * A serialized streamed-span attribute (`{ type, value }`), for strict `toEqual` assertions on + * `attributes` of a streamed span. Defaults to a string attribute. + */ +export function streamedAttribute(value: unknown, type = 'string'): { type: string; value: unknown } { + return { type, value }; +}