From cd4d0a3e27d5bc0cd3dbc7c63b4ab91b15e79a37 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 8 Oct 2026 11:11:40 +0200 Subject: [PATCH] feat(node): Add neonIntegration for @neondatabase/serverless Queries through the Neon serverless driver produced no db spans: the package bundles its own pg, so the pg instrumentation never sees it, and the HTTP driver only showed an http.client span without SQL. Subscribes to the orchestrion channels from the Neon config and registers it, so build-time bundler plugins get the same coverage as the runtime hook. The WebSocket driver reuses the pg query span builder. The HTTP executor inside neon() gets one span per request, covering sql.query(), tagged templates and transaction() batches without double counting lazy query promises. Connection attributes for HTTP come from the resolver call that runs inside that span. Fixes #25144 Co-Authored-By: Claude Fable 5.1 --- packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/deno/src/index.ts | 1 + packages/elysia/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + packages/node/src/index.ts | 1 + packages/server-utils/src/index.ts | 1 + .../server-utils/src/integrations/index.ts | 2 + .../server-utils/src/integrations/neon.ts | 158 ++++++++++ .../server-utils/src/orchestrion/channels.ts | 2 + .../config/channel-integration-definitions.ts | 1 + .../src/orchestrion/config/index.ts | 2 + .../test/integrations/neon.test.ts | 286 ++++++++++++++++++ .../test/orchestrion/neon-bundle.test.ts | 16 +- packages/solidstart/src/server/index.ts | 1 + packages/sveltekit/src/server/index.ts | 1 + 17 files changed, 475 insertions(+), 2 deletions(-) create mode 100644 packages/server-utils/src/integrations/neon.ts create mode 100644 packages/server-utils/test/integrations/neon.test.ts diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 7c997a3de32f..dc77f7d5f6b9 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -109,6 +109,7 @@ export { pinoIntegration, postgresIntegration, postgresJsIntegration, + neonIntegration, prismaIntegration, openTelemetryIntegration, getOtlpTracesEndpoint, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index d5aabe372bd0..cd302d5523f0 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -129,6 +129,7 @@ export { pinoIntegration, postgresIntegration, postgresJsIntegration, + neonIntegration, processSessionIntegration, prismaIntegration, openTelemetryIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index aa7b2cfc74bf..c0cc191e2850 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -150,6 +150,7 @@ export { tediousIntegration, postgresIntegration, postgresJsIntegration, + neonIntegration, prismaIntegration, openTelemetryIntegration, getOtlpTracesEndpoint, diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index dd23caea66cc..c47dc1628c74 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -156,6 +156,7 @@ export { typesafeIntegration, postgresIntegration, postgresJsIntegration, + neonIntegration, tediousIntegration, eveConversationHook, eveIntegration, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 659cb35cf870..865ace1fc978 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -129,6 +129,7 @@ export { tediousIntegration, postgresIntegration, postgresJsIntegration, + neonIntegration, prismaIntegration, processSessionIntegration, hapiIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 828f77501a48..5bbf486e2342 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -129,6 +129,7 @@ export { pinoIntegration, postgresIntegration, postgresJsIntegration, + neonIntegration, prismaIntegration, openTelemetryIntegration, getOtlpTracesEndpoint, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 0dbf9dc692b9..df5d309ee9e6 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -38,6 +38,7 @@ export { typesafeIntegration, postgresIntegration, postgresJsIntegration, + neonIntegration, redisIntegration, tediousIntegration, vercelAIIntegration, diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 22b7470fa7b7..41eb3845cf1a 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -68,6 +68,7 @@ export { mysql2Integration } from './integrations/mysql2'; export { openAIIntegration } from './integrations/openai'; export { postgresIntegration } from './integrations/postgres'; export { postgresJsIntegration } from './integrations/postgres-js'; +export { neonIntegration } from './integrations/neon'; export { tediousIntegration } from './integrations/tedious'; export { vercelAIIntegration } from './integrations/vercel-ai'; export { expressIntegration } from './integrations/express'; diff --git a/packages/server-utils/src/integrations/index.ts b/packages/server-utils/src/integrations/index.ts index a09d590e1121..5b044dd29430 100644 --- a/packages/server-utils/src/integrations/index.ts +++ b/packages/server-utils/src/integrations/index.ts @@ -27,6 +27,7 @@ import { groqIntegration } from './groq'; import { togetherAIIntegration } from './together-ai'; import { typesafeIntegration } from './typesafe'; import { postgresJsIntegration } from './postgres-js'; +import { neonIntegration } from './neon'; import { firebaseIntegration } from './firebase'; import { expressIntegration } from './express'; import { fastifyIntegration } from './fastify'; @@ -70,6 +71,7 @@ export function getTracingIntegrations(): Integration[] { togetherAIIntegration(), typesafeIntegration(), postgresJsIntegration(), + neonIntegration(), firebaseIntegration(), mcpServerIntegration(), ]; diff --git a/packages/server-utils/src/integrations/neon.ts b/packages/server-utils/src/integrations/neon.ts new file mode 100644 index 000000000000..f7e66ff953a2 --- /dev/null +++ b/packages/server-utils/src/integrations/neon.ts @@ -0,0 +1,158 @@ +import * as diagnosticsChannel from '../utils/diagnosticsChannel'; +import { + DB_OPERATION_BATCH_SIZE, + DB_QUERY_SUMMARY, + DB_QUERY_TEXT, + DB_SYSTEM_NAME, + SENTRY_KIND, + SENTRY_OP, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; +import { DB } from '@sentry/conventions/op'; +import type { IntegrationFn, Span } from '@sentry/core'; +import { defineIntegration, getActiveSpan, startInactiveSpan } from '@sentry/core'; +import { sanitizeSqlQueryWithSummary } from '../utils/sql'; +import { CHANNELS } from '../orchestrion/channels'; +import { neonModuleNames } from '../orchestrion/config/neon'; +import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; +import { bindTracingChannelToSpan } from '../tracing-channel'; +import { bindPgQueryChannel, getConnectionAttributes } from './postgres'; + +const INTEGRATION_NAME = 'Neon' as const; +const ORIGIN = 'auto.db.neon'; +const DB_SYSTEM_POSTGRESQL = 'postgresql'; +const DEFAULT_PORT = 5432; +const NOOP = (): void => {}; + +// `sql.query(text, params)` builds the first shape, a tagged-template call the second. +type NeonQueryData = { query: string; params?: unknown[] } | NeonSqlTemplate; + +interface NeonSqlTemplate { + strings: readonly string[]; + values: unknown[]; + toParameterizedQuery?: () => { query: string }; +} + +interface NeonHttpQueryContext { + // `[queryData, opts]` for a single query, `[queryData[], opts[]]` for a `transaction()` batch. + arguments: unknown[]; +} + +interface NeonResolveConnectionContext { + result?: { resolvedURL?: URL }; +} + +// The HTTP query spans this integration started, so the connection-resolver subscriber only +// enriches those and never a user's own active span. +const httpQuerySpans = new WeakSet(); + +const _neonIntegration = (() => { + return { + name: INTEGRATION_NAME, + setup(client) { + invokeOrchestrionInstrumentation(client, neonModuleNames, instrumentNeon, []); + }, + }; +}) satisfies IntegrationFn; + +function instrumentNeon(): void { + // The WebSocket driver is pg's `Client`, bundled into the package, so its query channel gets + // the pg span shape. + bindPgQueryChannel(CHANNELS.NEON_QUERY, { origin: ORIGIN }); + + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.NEON_HTTP_QUERY), + data => { + const span = startInactiveSpan(httpQuerySpanOptions(data.arguments)); + httpQuerySpans.add(span); + return span; + }, + { requiresParentSpan: true }, + ); + + // The connection string is resolved inside the HTTP executor, so the query span is active + // when this settles. The executor's own context never sees the string. + diagnosticsChannel.tracingChannel(CHANNELS.NEON_RESOLVE_CONNECTION).subscribe({ + start: NOOP, + end: NOOP, + asyncStart: NOOP, + error: NOOP, + asyncEnd(data) { + const span = getActiveSpan(); + const url = data.result?.resolvedURL; + if (!span || !httpQuerySpans.has(span) || !url) { + return; + } + span.setAttributes( + getConnectionAttributes({ + host: url.hostname, + port: Number(url.port) || DEFAULT_PORT, + database: url.pathname.slice(1), + user: url.username || undefined, + }), + ); + }, + }); +} + +function httpQuerySpanOptions(args: unknown[]): Parameters[0] { + const queryData = args[0] as NeonQueryData | NeonQueryData[] | undefined; + const batchSize = Array.isArray(queryData) ? queryData.length : undefined; + // A batch gets one span whose text and summary list every statement, in order. + const statements = (Array.isArray(queryData) ? queryData : [queryData]) + .map(getQueryText) + .filter((text): text is string => !!text) + .map(text => sanitizeSqlQueryWithSummary(text)); + const queryText = joinDefined(statements.map(s => s.queryText)); + const querySummary = joinDefined(statements.map(s => s.querySummary)); + const name = querySummary || DB_SYSTEM_POSTGRESQL; + + return { + name, + attributes: { + [SENTRY_OP]: DB, + [SENTRY_ORIGIN]: ORIGIN, + [SENTRY_KIND]: 'client', + [DB_SYSTEM_NAME]: DB_SYSTEM_POSTGRESQL, + [DB_QUERY_TEXT]: queryText || undefined, + [DB_QUERY_SUMMARY]: querySummary, + [DB_OPERATION_BATCH_SIZE]: batchSize, + }, + }; +} + +function joinDefined(parts: (string | undefined)[]): string | undefined { + const defined = parts.filter((part): part is string => !!part); + return defined.length ? defined.join('; ') : undefined; +} + +function getQueryText(data: NeonQueryData | undefined): string | undefined { + if (!data || typeof data !== 'object') { + return undefined; + } + if ('query' in data && typeof data.query === 'string') { + return data.query; + } + if (!('strings' in data) || !Array.isArray(data.strings)) { + return undefined; + } + if (typeof data.toParameterizedQuery === 'function') { + try { + // Neon's own rendering also inlines `sql.unsafe()` fragments and nested templates. + return data.toParameterizedQuery().query; + } catch { + // fall through to the plain placeholder rendering + } + } + return data.strings.reduce((text, part, i) => `${text}$${i}${part}`); +} + +/** + * Diagnostics-channel-based `@neondatabase/serverless` integration. + * + * Subscribes to the channels Sentry's code transform injects into the package's bundled pg + * `Client.prototype.query` (WebSocket driver) and into the HTTP executor behind `neon()`, so both + * drivers emit `db` spans with the same shape as the `pg` integration. Requires the Sentry runtime + * hook or bundler plugin to be active. + */ +export const neonIntegration = defineIntegration(_neonIntegration); diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 4211c8c88d5c..b5919b9eb071 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -24,6 +24,7 @@ import { mongodbChannels } from './config/mongodb'; import { mongooseChannels } from './config/mongoose'; import { mysql2Channels } from './config/mysql2'; import { mysqlChannels } from './config/mysql'; +import { neonChannels } from './config/neon'; import { nestjsChannels } from './config/nestjs'; import { openaiChannels } from './config/openai'; import { pgChannels } from './config/pg'; @@ -80,6 +81,7 @@ export const CHANNELS = { ...mongooseChannels, ...mysql2Channels, ...mysqlChannels, + ...neonChannels, ...nestjsChannels, ...openaiChannels, ...pgChannels, diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index 683370c0fe61..d3583a3977ec 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -18,6 +18,7 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ { exportName: 'postgresIntegration', modules: ['pg', 'pg-pool'] }, { exportName: 'postgresJsIntegration', modules: ['postgres'] }, + { exportName: 'neonIntegration', modules: ['@neondatabase/serverless'] }, { exportName: 'prismaIntegration', modules: ['@prisma/orm-family-sql'] }, { exportName: 'mysqlIntegration', modules: ['mysql'] }, { exportName: 'mysql2Integration', modules: ['mysql2'] }, diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 2c6a7835db1a..0cf0e363728f 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -30,6 +30,7 @@ import { mongodbConfig } from './mongodb'; import { mongooseConfig } from './mongoose'; import { mysql2Config } from './mysql2'; import { mysqlConfig } from './mysql'; +import { neonConfig } from './neon'; import { nestjsConfig } from './nestjs'; import { openaiConfig } from './openai'; import { pgConfig } from './pg'; @@ -86,6 +87,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...mongooseConfig, ...mysql2Config, ...mysqlConfig, + ...neonConfig, ...nestjsConfig, ...openaiConfig, ...pgConfig, diff --git a/packages/server-utils/test/integrations/neon.test.ts b/packages/server-utils/test/integrations/neon.test.ts new file mode 100644 index 000000000000..661a65cc7389 --- /dev/null +++ b/packages/server-utils/test/integrations/neon.test.ts @@ -0,0 +1,286 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +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 type { Client, Scope, Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + getDefaultCurrentScope, + getDefaultIsolationScope, + GLOBAL_OBJ, + setAsyncContextStrategy, +} from '@sentry/core'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { neonIntegration } from '../../src/integrations/neon'; +import { CHANNELS } from '../../src/orchestrion/channels'; + +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +// Same minimal async-context strategy as the pg tests: `bindTracingChannelToSpan` only binds +// when a `getTracingChannelBinding` exists. +function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return asyncStorage.getStore() || { scope: getDefaultCurrentScope(), isolationScope: getDefaultIsolationScope() }; + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), + }); +} + +function makeSpan(): Span { + return { end: vi.fn(), setStatus: vi.fn(), setAttributes: vi.fn() } as unknown as Span; +} + +interface ChannelContext { + arguments: unknown[]; + self?: unknown; + result?: unknown; +} + +const CONNECTION = { database: 'neondb', host: 'ep-x.aws.neon.tech', port: 5432, user: 'tim' }; + +// Shape of `@neondatabase/serverless`'s `SqlTemplate`, the query data a tagged-template call builds. +function sqlTemplate(strings: string[], values: unknown[]): unknown { + return { + strings, + values, + toParameterizedQuery() { + return { query: strings.reduce((acc, s, i) => `${acc}$${i}${s}`), params: values }; + }, + }; +} + +describe('neonIntegration', () => { + let startInactiveSpanSpy: MockInstance; + let getActiveSpanSpy: MockInstance; + let span: Span; + + beforeAll(() => { + installTestAsyncContextStrategy(); + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['@neondatabase/serverless'] }; + neonIntegration().setup?.({ on: () => () => undefined } as unknown as Client); + }); + + afterAll(() => { + setAsyncContextStrategy(undefined); + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + }); + + beforeEach(() => { + span = makeSpan(); + startInactiveSpanSpy = vi.spyOn(SentryCore, 'startInactiveSpan').mockReturnValue(span); + getActiveSpanSpy = vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({} as Span); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('websocket driver (`Client.prototype.query`)', () => { + it('builds a db span with the pg connection attributes and the neon origin', async () => { + const ctx: ChannelContext = { arguments: ['SELECT * FROM "User"'], self: { connectionParameters: CONNECTION } }; + + await tracingChannel(CHANNELS.NEON_QUERY).tracePromise(async () => ({ rows: [] }), ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'SELECT * FROM "User"', + attributes: expect.objectContaining({ + [SENTRY_OP]: DB, + [SENTRY_KIND]: 'client', + [DB_SYSTEM_NAME]: 'postgresql', + [DB_NAMESPACE]: 'neondb', + [DB_USER]: 'tim', + [SERVER_ADDRESS]: 'ep-x.aws.neon.tech', + [SERVER_PORT]: 5432, + [DB_QUERY_TEXT]: 'SELECT * FROM "User"', + [SENTRY_ORIGIN]: 'auto.db.neon', + }), + }), + ); + // The deprecated `db.connection_string` is left to the pg integration. + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ 'db.connection_string': expect.anything() }), + }), + ); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('does not start a span without a parent span', async () => { + getActiveSpanSpy.mockReturnValue(undefined); + const ctx: ChannelContext = { arguments: ['SELECT 1'], self: { connectionParameters: CONNECTION } }; + + await tracingChannel(CHANNELS.NEON_QUERY).tracePromise(async () => ({ rows: [] }), ctx); + + expect(startInactiveSpanSpy).not.toHaveBeenCalled(); + }); + }); + + describe('http driver (`neon()`)', () => { + it('builds a db span for `sql.query(text, params)`', async () => { + const ctx: ChannelContext = { + arguments: [{ query: 'SELECT * FROM "User" WHERE id = $1', params: [1] }, undefined], + }; + + await tracingChannel(CHANNELS.NEON_HTTP_QUERY).tracePromise(async () => [], ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'SELECT "User"', + attributes: expect.objectContaining({ + [SENTRY_OP]: DB, + [SENTRY_KIND]: 'client', + [DB_SYSTEM_NAME]: 'postgresql', + [DB_QUERY_TEXT]: 'SELECT * FROM "User" WHERE id = $1', + [DB_QUERY_SUMMARY]: 'SELECT "User"', + [SENTRY_ORIGIN]: 'auto.db.neon', + [DB_OPERATION_BATCH_SIZE]: undefined, + }), + }), + ); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('renders a tagged-template query with positional placeholders', async () => { + const ctx: ChannelContext = { + arguments: [sqlTemplate(['SELECT * FROM "User" WHERE id = ', ' AND name = ', ''], [1, 'tim'])], + }; + + await tracingChannel(CHANNELS.NEON_HTTP_QUERY).tracePromise(async () => [], ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'SELECT "User"', + attributes: expect.objectContaining({ + [DB_QUERY_TEXT]: 'SELECT * FROM "User" WHERE id = $1 AND name = $2', + }), + }), + ); + }); + + it('builds one span for a `transaction()` batch with the batch size', async () => { + const ctx: ChannelContext = { + arguments: [ + [ + { query: 'INSERT INTO "User" ("name") VALUES ($1)', params: ['tim'] }, + sqlTemplate(['SELECT * FROM "User"'], []), + ], + [{}, {}], + ], + }; + + await tracingChannel(CHANNELS.NEON_HTTP_QUERY).tracePromise(async () => [[], []], ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'INSERT "User"; SELECT "User"', + attributes: expect.objectContaining({ + [DB_QUERY_TEXT]: 'INSERT INTO "User" ("name") VALUES ($1); SELECT * FROM "User"', + [DB_QUERY_SUMMARY]: 'INSERT "User"; SELECT "User"', + [DB_OPERATION_BATCH_SIZE]: 2, + [SENTRY_ORIGIN]: 'auto.db.neon', + }), + }), + ); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('sets error status and ends the span when the request rejects', async () => { + const ctx: ChannelContext = { arguments: [{ query: 'SELECT 1', params: [] }] }; + + await expect( + tracingChannel(CHANNELS.NEON_HTTP_QUERY).tracePromise(async () => { + throw new Error('boom'); + }, ctx), + ).rejects.toThrow('boom'); + + expect(span.setStatus).toHaveBeenCalledWith({ code: expect.anything(), message: 'boom' }); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('adds the connection attributes once the connection string resolves inside the query', async () => { + const ctx: ChannelContext = { arguments: [{ query: 'SELECT 1', params: [] }] }; + + await tracingChannel(CHANNELS.NEON_HTTP_QUERY).tracePromise(async () => { + // The resolver runs inside the query span's async context. + getActiveSpanSpy.mockReturnValue(span); + await tracingChannel(CHANNELS.NEON_RESOLVE_CONNECTION).tracePromise( + async () => ({ + resolvedConnectionString: 'postgresql://tim:secret@ep-x.aws.neon.tech/neondb?application_name=x', + resolvedURL: new URL('postgresql://tim:secret@ep-x.aws.neon.tech/neondb?application_name=x'), + }), + { arguments: ['postgresql://tim:secret@ep-x.aws.neon.tech/neondb'] }, + ); + }, ctx); + + // No port in the URL means the Postgres default, like pg's own connection parameters. + expect(span.setAttributes).toHaveBeenCalledWith({ + [DB_SYSTEM_NAME]: 'postgresql', + [DB_NAMESPACE]: 'neondb', + [DB_USER]: 'tim', + [SERVER_ADDRESS]: 'ep-x.aws.neon.tech', + [SERVER_PORT]: 5432, + }); + }); + + it('leaves spans it did not start alone when the connection string resolves', async () => { + const foreignSpan = makeSpan(); + getActiveSpanSpy.mockReturnValue(foreignSpan); + + await tracingChannel(CHANNELS.NEON_RESOLVE_CONNECTION).tracePromise( + async () => ({ resolvedURL: new URL('postgresql://tim:secret@ep-x.aws.neon.tech/neondb') }), + { arguments: [] }, + ); + + expect(foreignSpan.setAttributes).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/server-utils/test/orchestrion/neon-bundle.test.ts b/packages/server-utils/test/orchestrion/neon-bundle.test.ts index e84a1d35ebe9..79d9907467e2 100644 --- a/packages/server-utils/test/orchestrion/neon-bundle.test.ts +++ b/packages/server-utils/test/orchestrion/neon-bundle.test.ts @@ -6,7 +6,9 @@ import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { tracingChannel } from 'node:diagnostics_channel'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { neonChannels, neonConfig } from '../../src/orchestrion/config/neon'; +import { SENTRY_INSTRUMENTATIONS } from '../../src/orchestrion/config'; +import { neonChannels } from '../../src/orchestrion/config/neon'; +import { orchestrionTransformOptions } from '../../src/orchestrion/bundler/options'; // The selectors key on internals of Neon's minified bundles, so this runs the bundler plugins' // code transformer over the real published files and executes the result: a release that @@ -63,11 +65,21 @@ describe('neon orchestrion config on the published bundles', () => { afterAll(() => rmSync(outDir, { recursive: true, force: true })); + it('injects the subscriber snippet for the neon integration', () => { + const transformer = createCodeTransformer(orchestrionTransformOptions({})); + const result = transformer.transform( + readFileSync(join(packageDir, 'index.js'), 'utf8'), + join(packageDir, 'index.js'), + ); + + expect(result?.code).toContain('orchestrionModuleInjected("@neondatabase/serverless", () => neonIntegration())'); + }); + it.each([ ['index.js', 'cjs'], ['index.mjs', 'esm'], ])('instruments both drivers in %s', async (file, format) => { - const transformer = createCodeTransformer({ instrumentations: neonConfig }); + const transformer = createCodeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }); const source = join(packageDir, file); const result = transformer.transform(readFileSync(source, 'utf8'), source); expect(result).not.toBeNull(); diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 8c8576aeb7ae..fc4f29d21181 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -82,6 +82,7 @@ export { parameterize, postgresIntegration, postgresJsIntegration, + neonIntegration, prismaIntegration, redisIntegration, requestDataIntegration, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index cb0730b4c513..d01ea0d03fc2 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -81,6 +81,7 @@ export { parameterize, postgresIntegration, postgresJsIntegration, + neonIntegration, prismaIntegration, redisIntegration, requestDataIntegration,