diff --git a/packages/core/src/integrations/postgresjs.ts b/packages/core/src/integrations/postgresjs.ts index bb45aedbc4b0..93602d71a6e5 100644 --- a/packages/core/src/integrations/postgresjs.ts +++ b/packages/core/src/integrations/postgresjs.ts @@ -217,8 +217,10 @@ function _wrapSingleQueryHandle( // IMPORTANT: We must replace the handle function directly, not use a Proxy, // because Query.then() internally calls this.handle(), which would bypass a Proxy wrapper. - const wrappedHandle = async function (this: unknown, ...args: unknown[]): Promise { - if (!_shouldCreateSpans(options)) { + const wrappedHandle = async function (this: { executed?: boolean }, ...args: unknown[]): Promise { + // postgres.js calls handle() from then/catch/finally — only the first call executes SQL, + // subsequent calls are no-ops (guarded by this.executed). Skip span creation for no-ops. + if (this.executed || !_shouldCreateSpans(options)) { return originalHandle.apply(this, args); } diff --git a/packages/core/test/lib/integrations/postgresjs.test.ts b/packages/core/test/lib/integrations/postgresjs.test.ts index dfc159808377..921e82b89a42 100644 --- a/packages/core/test/lib/integrations/postgresjs.test.ts +++ b/packages/core/test/lib/integrations/postgresjs.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { _reconstructQuery, _sanitizeSqlQuery, instrumentPostgresJsSql } from '../../../src/integrations/postgresjs'; +import * as tracing from '../../../src/tracing'; import * as spanUtils from '../../../src/utils/spanUtils'; describe('PostgresJs portable instrumentation', () => { @@ -554,6 +555,42 @@ describe('PostgresJs portable instrumentation', () => { // handle was wrapped expect((mockQuery.handle as any).__sentryWrapped).toBe(true); }); + + it('only creates one span even when handle() is called multiple times', async () => { + const mockSpan = { setAttribute: vi.fn(), setAttributes: vi.fn(), end: vi.fn() }; + const startSpanManualSpy = vi + .spyOn(tracing, 'startSpanManual') + .mockImplementation((_opts, callback) => callback(mockSpan as any, () => {})); + + const originalHandle = vi.fn().mockResolvedValue([]); + const mockQuery = { + handle: originalHandle, + strings: ['SELECT 1'], + resolve: vi.fn(), + reject: vi.fn(), + executed: false, + }; + const mockSql = vi.fn().mockReturnValue(mockQuery); + + const instrumented = instrumentPostgresJsSql(mockSql, { requireParentSpan: false }); + instrumented(['SELECT 1']); + + const wrappedHandle = mockQuery.handle as (...args: unknown[]) => Promise; + + // First call — executed is false, should create a span + await wrappedHandle.call(mockQuery); + expect(startSpanManualSpy).toHaveBeenCalledTimes(1); + + // Simulate postgres.js setting executed = true after first handle() + mockQuery.executed = true; + + // Second and third calls (from .then/.catch/.finally) — should NOT create more spans + await wrappedHandle.call(mockQuery); + await wrappedHandle.call(mockQuery); + expect(startSpanManualSpy).toHaveBeenCalledTimes(1); + + startSpanManualSpy.mockRestore(); + }); }); it('does not wrap non-query results from sql call', () => { diff --git a/packages/node/src/integrations/tracing/postgresjs.ts b/packages/node/src/integrations/tracing/postgresjs.ts index cbc5647e6f21..62f9a01ccfc8 100644 --- a/packages/node/src/integrations/tracing/postgresjs.ts +++ b/packages/node/src/integrations/tracing/postgresjs.ts @@ -281,11 +281,14 @@ export class PostgresJsInstrumentation extends InstrumentationBase { - // Skip if this query came from an instrumented sql instance (already handled by wrapper) - if ((this as Record)[QUERY_FROM_INSTRUMENTED_SQL]) { + // Skip if this query came from an instrumented sql instance (already handled by wrapper), + // or if handle() was already called (postgres.js calls handle() from then/catch/finally — + // only the first call executes SQL, subsequent calls are no-ops). + if (this.executed || (this as Record)[QUERY_FROM_INSTRUMENTED_SQL]) { return originalHandle.apply(this, args); }