Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dev-packages/bun-integration-tests/node-suites/excludes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions dev-packages/node-integration-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
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
Comment thread
cursor[bot] marked this conversation as resolved.
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,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();
Original file line number Diff line number Diff line change
@@ -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();
146 changes: 146 additions & 0 deletions dev-packages/node-integration-tests/suites/tracing/neon/test.ts
Original file line number Diff line number Diff line change
@@ -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"' }),
]);
});
});
});
});
Loading